Merge "Revert "Add topic to polygerrit index paths""
diff --git a/.bazelignore b/.bazelignore
index 69c04b1..aac80af 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,2 +1,5 @@
 eclipse-out
 node_modules
+polygerrit-ui/node_modules
+plugins/node_modules
+tools/node_tools/node_modules
diff --git a/.bazelproject b/.bazelproject
index a7f5450..ad7b022 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -16,7 +16,7 @@
 targets:
   //...:all
 
-java_language_level: 8
+java_language_level: 11
 
 workspace_type: java
 
diff --git a/.bazelrc b/.bazelrc
index 6ccd56a..407b005 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -2,7 +2,30 @@
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain=//tools:error_prone_warnings_toolchain_java11
+
+# Builds using remotejdk_11, executes using remotejdk_11 or local_jdk
+build --java_language_version=11
+build --java_runtime_version=remotejdk_11
+build --tool_java_language_version=11
+build --tool_java_runtime_version=remotejdk_11
+
+# Builds using remotejdk_17, executes using remotejdk_17 or local_jdk
+build:java17 --java_language_version=17
+build:java17 --java_runtime_version=remotejdk_17
+build:java17 --tool_java_language_version=17
+build:java17 --tool_java_runtime_version=remotejdk_17
+
+# Builds and executes on RBE using remotejdk_11
+build:remote --java_language_version=11
+build:remote --java_runtime_version=remotejdk_11
+build:remote --tool_java_language_version=11
+build:remote --tool_java_runtime_version=remotejdk_11
+
+# Builds and executes on RBE using remotejdk_17
+build:remote17 --java_language_version=17
+build:remote17 --java_runtime_version=remotejdk_17
+build:remote17 --tool_java_language_version=17
+build:remote17 --tool_java_runtime_version=remotejdk_17
 
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -13,12 +36,7 @@
 
 build --announce_rc
 
-# Workaround Bazel worker crash (remove after upgrading to 4.1.0)
-# https://github.com/bazelbuild/bazel/issues/13333
-build --experimental_worker_multiplex=false
-
 test --build_tests_only
-test --test_output=errors
-test --java_toolchain=//tools:error_prone_warnings_toolchain_java11
+test --test_output=all
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
index fcdb2e1..c7cb131 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-4.0.0
+5.3.1
diff --git a/.gitignore b/.gitignore
index 95f94ba..0bbcaba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 # Keep following lines sorted according to `LC_COLLATE=C sort`
+*.code-workspace
 *.eml
 *.iml
 *.log
@@ -30,8 +31,13 @@
 /infer-out
 /local.properties
 /node_modules/
+/polygerrit-ui/node_modules/
+/polygerrit-ui/app/node_modules/
 /package-lock.json
 /plugins/*
+/polygerrit-ui/coverage/
+/polygerrit-ui/app/plugins/*
+/polygerrit-ui/screenshots/Chrome/failed/
 !/plugins/.eslintignore
 !/plugins/.eslintrc.js
 !/plugins/.prettierrc.js
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 8efbd11..09ce63b 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -10,9 +10,9 @@
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
 org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=11
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=1.8
+org.eclipse.jdt.core.compiler.compliance=11
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
@@ -29,6 +29,7 @@
 org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
 org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
 org.eclipse.jdt.core.compiler.problem.emptyStatement=warning
+org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
 org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
 org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=warning
 org.eclipse.jdt.core.compiler.problem.fallthroughCase=warning
@@ -87,6 +88,7 @@
 org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore
 org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
 org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
+org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning
 org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
 org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning
 org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
@@ -126,4 +128,5 @@
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
-org.eclipse.jdt.core.compiler.source=1.8
+org.eclipse.jdt.core.compiler.release=enabled
+org.eclipse.jdt.core.compiler.source=11
diff --git a/BUILD b/BUILD
index 084d383..984fd955 100644
--- a/BUILD
+++ b/BUILD
@@ -4,24 +4,17 @@
 package(default_visibility = ["//visibility:public"])
 
 config_setting(
-    name = "java11",
+    name = "java17",
     values = {
-        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_java11",
-    },
-)
-
-config_setting(
-    name = "java_next",
-    values = {
-        "java_toolchain": "//tools:toolchain_vanilla",
+        "java_language_version": "17",
     },
 )
 
 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 > $@"),
+    cmd = ("(cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
+           "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2) > $@ || echo 'UNKNOWN' > $@"),
     stamp = 1,
 )
 
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3da69df..185fa07 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -800,6 +800,22 @@
 Users without this access right who are able to upload changes can
 still do the revert locally and upload the revert commit as a new change.
 
+[[category_remove_label]]
+=== Remove Label (Remove Vote)
+
+For every configured label `My-Name` in the project, there is a
+corresponding permission `removeLabel-My-Name` with a range corresponding to
+the defined values. For these values, the users are permitted to remove
+other users' votes from a change.
+
+Change owners can always remove zero or positive votes (even without
+having the `Remove Vote` access right assigned).
+
+Project owners and site administrators can always remove any vote (even
+without having the `Remove Vote` access right assigned).
+
+Users without this access right can still remove their own votes.
+
 [[category_remove_reviewer]]
 === Remove Reviewer
 
@@ -890,6 +906,9 @@
 the Work In Progress bit of the change (even without having the
 `Toggle Work In Progress state` access right assigned).
 
+Must be assigned on the target branch ref (i.e. on 'refs/heads/*', not on
+'refs/for/*').
+
 [[category_delete_own_changes]]
 === Delete Own Changes
 
@@ -1351,10 +1370,11 @@
 [[capability_createProject]]
 === Create Project
 
-Allow project creation.  This capability allows the granted group to
-either link:cmd-create-project.html[create new git projects via ssh]
-or via the web UI.
+Allow project creation.
 
+This capability allows the granted group to create projects via the web UI, via
+link:rest-api-projects.html#create-project][REST] and via
+link:cmd-create-project.html[SSH].
 
 [[capability_emailReviewers]]
 === Email Reviewers
diff --git a/Documentation/backend_licenses.txt b/Documentation/backend_licenses.txt
index 3113682..586406f 100755
--- a/Documentation/backend_licenses.txt
+++ b/Documentation/backend_licenses.txt
@@ -61,11 +61,8 @@
 * guice:guice-library
 * guice:guice-servlet
 * guice:javax_inject
-* httpcomponents:httpasyncclient
 * httpcomponents:httpclient
 * httpcomponents:httpcore
-* httpcomponents:httpcore-nio
-* jackson:jackson-core
 * jetty:http
 * jetty:io
 * jetty:jmx
@@ -1113,22 +1110,6 @@
 ----
 
 
-[[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/).
-
-----
-
-
 [[flexmark]]
 flexmark
 
diff --git a/Documentation/backup.txt b/Documentation/backup.txt
index dd47035..9139e71 100644
--- a/Documentation/backup.txt
+++ b/Documentation/backup.txt
@@ -45,10 +45,6 @@
 It can be recomputed from primary data in the git repositories but
 reindexing may take a long time hence backing up the index makes sense
 for production installations.
-+
-If you have chosen to use _Elastic Search_ for indexing,
-refer to its
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html[backup documentation,role=external,window=_blank].
 
 [#optional-backup-cache]
 Caches::
diff --git a/Documentation/cmd-check-project-access.txt b/Documentation/cmd-check-project-access.txt
new file mode 100644
index 0000000..885993a
--- /dev/null
+++ b/Documentation/cmd-check-project-access.txt
@@ -0,0 +1,74 @@
+= gerrit check-project-access
+
+== NAME
+gerrit check-project-access - Check project readability of all users in a
+matching the given link:rest-api-accounts.html#account-id[account identifier]
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit check-project-access_
+  [--project <PROJECT> | -p <PROJECT>]
+  [--user <USER> | -u <USER>]
+--
+
+== DESCRIPTION
+Allow users to check if user has access to a project’s changes, comments, code
+differences, and Git access over SSH or HTTP.
+
+It returns all users in given input String, where it includes: username, email
+address and full name.
+
+== ACCESS
+Users who have view access and administrate server capability.
+
+== EXAMPLES
+Check if users can read all references in the repository called "test_project",
+in given input String TestUser.
+
+Given that there are
+a user with username "test_user1", email "one@email.com", and Full name as
+TestUser,
+a user with username "test_user2", email "two@email.com", and Full name as
+TestUser,
+a user with username "test_user3", email "TestUser@email.com", and Full name
+as John Doe
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u TestUser
+
+Username: 'test_user1', Email: 'one@example.com',  Full Name: 'TestUser'
+, Result: TRUE
+Username: 'test_user3', Email: 'TestUser@example.com',  Full Name: 'John Doe'
+, Result: FALSE
+Username: 'test_user2', Email: 'two@example.com',  Full Name: 'TestUser'
+, Result: FALSE
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project_doesnt_exist
+  -u TestUser
+
+fatal: project 'test_project_doesnt_exist' is unavailable
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u test_user_doesnt_exist
+
+fatal: No accounts found for your query: "test_user_doesnt_exist"
+Tip: Try double-escaping spaces, for example: "--user Last,\\ First"
+----
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
+
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 0575eb9..87f3851 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -108,6 +108,7 @@
 	Action used by Gerrit to submit an approved change to its
 	destination branch.  Supported options are:
 +
+* INHERIT: inherits the submit-type from the parent project.
 * FAST_FORWARD_ONLY: produces a strictly linear history.
 * MERGE_IF_NECESSARY: create a merge commit when required.
 * REBASE_IF_NECESSARY: rebase the commit when required.
@@ -116,7 +117,7 @@
 * CHERRY_PICK: always cherry-pick the commit.
 
 +
-Defaults to MERGE_IF_NECESSARY unless
+Defaults to INHERIT unless
 link:config-gerrit.html#repository.name.defaultSubmitType[
 repository.<name>.defaultSubmitType] is set to a different value.
 For more details see link:config-project-config.html#submit-type[
@@ -136,13 +137,13 @@
 	project.  Disabled by default.
 
 --use-signed-off-by::
---so:
+--so::
 	If enabled, each change must contain a Signed-off-by line
 	from either the author or the uploader in the commit message.
 	Disabled by default.
 
 --create-new-change-for-all-not-in-target::
---ncfa:
+--ncfa::
 	If enabled, a new change is created for every commit that is not in
 	the target branch. If the pushed commit is a merge commit, this flag is
 	ignored for that push. To avoid accidental creation of a large number
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index ab341e8..c959a07 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -58,6 +58,12 @@
 link:cmd-ban-commit.html[gerrit ban-commit]::
 	Bans a commit from a project's repository.
 
+link:cmd-copy-approvals.html[gerrit copy-approvals]::
+	Copy all inferred approvals labels to the latest patch-set.
+
+link:cmd-check-project-access.html[gerrit check-project-access]::
+	Check if user(s) can read non-config refs of a project
+
 link:cmd-create-branch.html[gerrit create-branch]::
 	Create a new project branch.
 
@@ -157,6 +163,9 @@
 link:cmd-ls-user-refs.html[gerrit ls-user-refs]::
 	Lists refs visible for a specified user.
 
+link:cmd-migrate-externalids-to-insensitive.html[gerrit migrate-externalids-to-insensitive]::
+	Migrate external-ids to case insensitive.
+
 link:cmd-plugin-install.html[gerrit plugin add]::
 	Alias for 'gerrit plugin install'.
 
diff --git a/Documentation/cmd-ls-groups.txt b/Documentation/cmd-ls-groups.txt
index 8a4845c..0a06c28 100644
--- a/Documentation/cmd-ls-groups.txt
+++ b/Documentation/cmd-ls-groups.txt
@@ -11,7 +11,7 @@
   [--user <NAME> | -u <NAME>]
   [--owned]
   [--visible-to-all]
-  [-q <GROUP>]
+  [-g <GROUP>]
   [--verbose | -v]
 --
 
@@ -67,8 +67,8 @@
 	(groups that are explicitly marked as visible to all registered
 	users).
 
--q::
-	Group that should be inspected. The `-q` option can be specified
+-g::
+	Group that should be inspected. The `-g` option can be specified
 	multiple times to define several groups to be inspected. If
 	specified the listed groups will only contain groups that were
 	specified to be inspected. This is e.g. useful in combination with
diff --git a/Documentation/cmd-migrate-externalids-to-insensitive.txt b/Documentation/cmd-migrate-externalids-to-insensitive.txt
new file mode 100644
index 0000000..b023089
--- /dev/null
+++ b/Documentation/cmd-migrate-externalids-to-insensitive.txt
@@ -0,0 +1,44 @@
+= gerrit migrate-externalids-to-insensitive
+
+== NAME
+gerrit migrate-externalids-to-insensitive - Migrate external-ids to case insensitive.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit migrate-externalids-to-insensitive_
+--
+
+== DESCRIPTION
+This command allows to trigger online conversion of `username` and
+`gerrit` external IDs to be handled case insensitively. This is done by
+recomputing the name of the note from the sha1 sum of the all lowercase
+external ID key, instead of preserving the key capitalization.
+
+The command requires link:#auth.userNameCaseInsensitive[auth.userNameCaseInsensitive] and
+link:#auth.userNameCaseInsensitiveMigrationMode[auth.userNameCaseInsensitiveMigrationMode] to
+be set to true to perform the migration.
+
+After the successful migration
+link:#auth.userNameCaseInsensitiveMigrationMode[auth.userNameCaseInsensitiveMigrationMode] is
+set to false.
+
+== ACCESS
+Caller must be a member of the privileged 'Administrators' group.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== EXAMPLES
+Start the online external ids migration:
+
+----
+$ ssh -p 29418 review.example.com gerrit migrate-externalids-to-insensitive
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index f0ad460..5fd0bfc 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -324,6 +324,19 @@
 
 comment:: Review comment cover message.
 
+=== Project Head Updated
+
+Sent when project's head is updated.
+
+type:: "project-head-updated"
+
+oldHead:: The old project head name
+
+newHead:: The new project head name
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 == SEE ALSO
 
 * link:json.html[JSON Data Formats]
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 0444fab..6e76a8a 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -34,6 +34,11 @@
 |Owner
 |The contributor who created the change.
 
+|Uploader
+|The user that uploaded the current patch set (e.g. the user that executed the
+`git push` command, or the user that triggered the patch set creation through
+an action in the UI).
+
 |Assignee
 |The contributor responsible for the change. Often used when a change has
 mulitple reviewers to identify the individual responsible for final approval.
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
index 274fbb0..e39d091 100644
--- a/Documentation/concept-patch-sets.txt
+++ b/Documentation/concept-patch-sets.txt
@@ -88,8 +88,8 @@
 evolves, such as "Added more unit tests." Unlike the change description, a patch
 set description does not become a part of the project's history.
 
-To add a patch set description, click *Add a patch set description*, located in
-the file list, or provide it link:user-upload.html#patch_set_description[on upload].
+To add a patch set description provide it
+link:user-upload.html#patch_set_description[on upload].
 
 GERRIT
 ------
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 7a7cef2..716fa2f 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -111,8 +111,8 @@
 Accessing the account data in Git is not fast enough for account
 queries, since it requires accessing all user branches and parsing
 all files in each of them. To overcome this Gerrit has a secondary
-index for accounts. The account index is either based on
-link:config-gerrit.html#index.type[Lucene or Elasticsearch].
+index for accounts. The account index is based on
+link:config-gerrit.html#index.type[Lucene].
 
 Via the link:rest-api-accounts.html#query-account[Query Account] REST
 endpoint link:user-search-accounts.html[generic account queries] are
@@ -383,8 +383,8 @@
 [[starred-changes]]
 == Starred Changes
 
-link:dev-stars.html[Starred changes] allow users to mark changes as
-favorites and receive email notifications for them.
+Starred changes allow users to mark changes as favorites and receive email
+notifications for them.
 
 Each starred change is a tuple of an account ID, a change ID and a
 label.
@@ -402,8 +402,7 @@
 when the prefix ends with '/', this ref format is optimized to find
 starred changes by change ID. Finding starred changes by change ID is
 e.g. needed when a change is updated so that all users that have
-the link:dev-stars.html#default-star[default star] on the change can be
-notified by email.
+the star on the change can be notified by email.
 
 Gerrit also needs an efficient way to find all changes that were
 starred by an account, e.g. to provide results for the
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3109ec7..8d30db2 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -256,8 +256,8 @@
 if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
 the password in the request is first checked against the HTTP password and, if
 it does not match, it is then validated against the LDAP password.
-Service users that only exist in the Gerrit database are authenticated by their
-HTTP passwords.
+Service users that are link:cmd-create-account.html[internal-only] are
+authenticated by their HTTP passwords.
 
 * `LDAP_BIND`
 +
@@ -561,7 +561,8 @@
 +
 To enable the actual usage of contributor agreement the project
 specific config option in the `project.config` must be set:
-link:config-project-config.html[receive.requireContributorAgreement].
+link:config-project-config.html#receive.requireContributorAgreement[
+receive.requireContributorAgreement].
 
 [[auth.trustContainerAuth]]auth.trustContainerAuth::
 +
@@ -611,7 +612,7 @@
 single call would trigger a full LDAP authentication and groups resolution
 which could introduce a noticeable latency on the overall execution
 and produce unwanted load to the LDAP server.
-+
+
 [[auth.gitOAuthProvider]]auth.gitOAuthProvider::
 +
 Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
@@ -666,8 +667,9 @@
 the link:config-accounts.html#external-ids[External ID documentation].
 +
 Gerrit provides the
-link:pgm-ChangeExternalIdCaseSensitivity.html[ChangeExternalIdCaseSensitivity tool]
-to migrate existing accounts to match the new scheme.
+link:pgm-ChangeExternalIdCaseSensitivity.html[offline]
+and the online link:externalid-case-insensitivity.html#online-migration[online]
+tools to migrate existing accounts to match the new scheme.
 +
 Naturally, if there were two accounts only different in capitalization,
 e.g. `johndoe` and `JohnDoe`, the account `JohnDoe` will not be able
@@ -676,6 +678,16 @@
 note name would be identical and thus conflict. These duplicates thus
 have to be deleted manually by deleting the respective external ID.
 +
+For newly initialized sites this option defaults to true.
++
+Default is false.
+
+[[auth.userNameCaseInsensitiveMigrationMode]]auth.userNameCaseInsensitiveMigrationMode::
++
+Setting migration mode to true allows to fallback to case sensitive
+behaviour if the migrated external ID cannot be found. This allows to
+trigger the migration while Gerrit process is running.
++
 Default is false.
 
 [[auth.enableRunAs]]auth.enableRunAs::
@@ -703,8 +715,10 @@
 [[auth.autoUpdateAccountActiveStatus]]auth.autoUpdateAccountActiveStatus::
 +
 Whether to allow automatic synchronization of an account's inactive flag upon login.
++
 If set to true, upon login, if the authentication back-end reports the account as active,
-the account's inactive flag in the internal Gerrit database will be updated to be active.
+the account's inactive flag in NoteDb will be updated to be active.
++
 If the authentication back-end reports the account as inactive, the account's flag will be
 updated to be inactive and the login attempt will be blocked. Users enabling this feature
 should ensure that their authentication back-end is supported. Currently, only
@@ -832,18 +846,21 @@
 entry is relatively the same, memoryLimit is currently defined to be
 the number of entries held by the cache (each entry costs 1).
 +
-For caches where the size of an entry can vary significantly between
-individual entries (notably `"diff"`, `"diff_intraline"`), memoryLimit
-is an approximation of the total number of bytes stored by the cache.
-Larger entries that represent bigger patch sets or longer source files
-will consume a bigger portion of the memoryLimit. For these caches the
-memoryLimit should be set to roughly the amount of RAM (in bytes) the
-administrator can dedicate to the cache.
+For caches where the size of an entry can vary significantly between individual
+entries (notably `"git_modified_files"`, `"modified_files"`, `"git_file_diff"`,
+`"gerrit_file_diff"`, `"diff_intraline"`), memoryLimit is an approximation of
+the total number of bytes stored by the cache.  Larger entries that represent
+bigger patch sets or longer source files will consume a bigger portion of the
+memoryLimit. For these caches the memoryLimit should be set to roughly the
+amount of RAM (in bytes) the administrator can dedicate to the cache.
 +
 Default is 1024 for most caches, except:
 +
 * `"adv_bases"`: default is `4096`
-* `"diff"`: default is `10m` (10 MiB of memory)
+* `"git_modified_files"`: default is `10m` (10 MiB of memory)
+* `"modified_files"`: default is `10m` (10 MiB of memory)
+* `"git_file_diff"`: default is `10m` (10 MiB of memory)
+* `"gerrit_file_diff"`: default is `10m` (10 MiB of memory)
 * `"diff_intraline"`: default is `10m` (10 MiB of memory)
 * `"diff_summary"`: default is `10m` (10 MiB of memory)
 * `"external_ids_map"`: default is `2` and should not be changed
@@ -854,8 +871,14 @@
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
-If set to 0 the cache is disabled. Entries are removed immediately
-after being stored by the cache. This is primarily useful for testing.
+If set to 0 the cache is disabled; entries are loaded but not stored
+in-memory.
++
+**NOTE**: When the cache is disabled, there is no locking when accessing
+the same key/value, and therefore multiple threads may
+load the same value concurrently with a higher memory footprint.
+To keep a minimum caching and avoid concurrent loading of the same
+key/value, set `memoryLimit` to `1` and `maxAge` to `1`.
 
 [[cache.name.expireFromMemoryAfterAccess]]cache.<name>.expireFromMemoryAfterAccess::
 +
@@ -940,12 +963,6 @@
 +
 If direct updates are made to `All-Users`, this cache should be flushed.
 
-cache `"approvals"`::
-+
-Cache entries contain approvals for a given patch set. This includes
-approvals granted on this patch set as well as approvals copied from
-earlier patch sets.
-
 cache `"adv_bases"`::
 +
 Used only for push over smart HTTP when branch level access controls
@@ -971,19 +988,41 @@
 this cache should be disabled in a cluster setup using multiple primary
 or multiple replica nodes.
 +
-The cache should be flushed whenever the database changes table is modified
-outside of Gerrit.
+The cache should be flushed whenever NoteDb change metadata in a repository is
+modified outside of Gerrit.
 
-cache `"diff"`::
+cache `"git_modified_files"`::
 +
-Each item caches the differences between two commits, at both the
-directory and file levels.  Gerrit uses this cache to accelerate
-the display of affected file names, as well as file contents.
+Each item caches the list of git modified files between two git trees
+corresponding to two different commits. This cache does not read the actual
+file contents nor does it include the edits (modified regions) of the files.
+
+cache `"modified_files"`::
++
+Each item caches the list of modified files between two commits. This cache
+is similar to the `git_modified_files` cache but performs extra logic including
+filtering out files that are untouched by both commits because they were purely
+modified between the parent commits.
+
+cache `"git_file_diff"`::
++
+Each item caches the pure git diff between two git trees for a specific file
+path. The diff includes all the file attributes (old/new paths, change/patch
+types) as well as the list of edits corresponding to the modified regions in
+the file.
+
+cache `"gerrit_file_diff"`::
++
+Each item caches the diff between two git commits for a specific file path.
+This cache is similar to the `git_file_diff` cache but performs extra logic
+including identifying the edits that are due to rebase. The diff for the
+"commit message" and "merge list" can also be requested from this cache.
 +
 Entries in this cache are relatively large, so memoryLimit is an
 estimate in bytes of memory used. Administrators should try to target
 cache.diff.memoryLimit to fit all changes users will view in a 1 or 2
-day span.
+day span. The same applies for other diff caches: `"git_modified_files"`,
+`"modified_files"` and `"git_file_diff"`.
 
 cache `"diff_intraline"`::
 +
@@ -1017,12 +1056,6 @@
 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 `true`.
 
 cache `"git_tags"`::
 +
@@ -1162,7 +1195,7 @@
 cache `"prolog_rules"`::
 +
 Caches parsed `rules.pl` contents for each project. This cache uses the same
-size as the `projects` cache, and cannot be configured independently.
+size as the `projects` cache when `cache.prolog_rules.memoryLimit` is not set.
 
 cache `"pure_revert"`::
 +
@@ -1206,9 +1239,9 @@
 
 ==== [[cache_options]]Cache Options
 
-[[cache.diff.timeout]]cache.diff.timeout::
+[[cache.git_file_diff.timeout]]cache.git_file_diff.timeout::
 +
-Maximum number of milliseconds to wait for diff data before giving up and
+Maximum number of milliseconds to wait for git diff data before giving up and
 falling back on a simpler diff algorithm that will not be able to break down
 modified regions into smaller ones. This is a work around for an infinite loop
 bug in the default difference algorithm implementation.
@@ -1283,6 +1316,21 @@
 +
 Default is the number of CPUs.
 
+[[cache.project_list.interval]]cache.project_list.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+the project_list cache warmer.
+
+By default, if `cache.project_list.maxAge` is set, `interval` will be set to
+half its value. If `cache.project_list.maxAge` is not set or `interval` is set
+to `-1`, it is disabled.
+
+[[cache.project_list.startTime]]cache.project_list.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+the project_list cache warmer.
+
+Default is 00:00 if the project_list cache warmer is enabled.
 
 [[capability]]
 === Section capability
@@ -1314,7 +1362,7 @@
 Whether the first user that logs in to the Gerrit server should
 automatically be added to the administrator group and hence get the
 `administrateServer` capability assigned. This is useful to bootstrap
-the authentication database.
+the link:config-accounts.html[account data].
 +
 Default is true.
 
@@ -1330,15 +1378,16 @@
 
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
-When reviewing merge commits, the left-hand side shows the output of the
-result of JGit's automatic merge algorithm. This option controls whether
-this output is cached in the change repository, or if only the diff is
-cached in the persistent `diff` cache.
+When reviewing merge commits, the left-hand side shows the output of the result
+of JGit's automatic merge algorithm. This option controls whether this output is
+cached in the change repository, or if only the diff is cached in the persistent
+diff caches (`"git_modified_files"`, `modified_files`, `"git_file_diff"`,
+`"file_diff"`).
 +
 If true, automerge results are stored in the repository under
 `refs/cache-automerge/*`; the results of diffing the change against its
-automerge base are stored in the diff cache. If false, no extra data is
-stored in the repository, only the diff cache. This can result in slight
+automerge base are stored in the diff caches. If false, no extra data is
+stored in the repository, only the diff caches. This can result in slight
 performance improvements by reducing the number of refs in the repo.
 +
 Default is true.
@@ -1399,7 +1448,7 @@
 Maximum number of patch sets allowed per change. If this is insufficient,
 recreate the change with a new Change-Id, then abandon the old change.
 +
-By default 1,500.
+By default 1000.
 
 [[change.maxUpdates]]change.maxUpdates::
 +
@@ -1465,6 +1514,13 @@
 
 Default is true.
 
+[[change.maxSubmittableAtOnce]]change.maxSubmittableAtOnce::
++
+Maximum number of changes that can be chained together in the same repository
+to be submitted at once.
++
+Default is 32767.
+
 [[change.move]]change.move::
 +
 Whether the link:rest-api-changes.html#move-change[Move Change] REST
@@ -1672,9 +1728,7 @@
 will match typical Gerrit Change-Id values and create a hyperlink
 to changes which reference it.  The second configuration 'bugzilla'
 will hyperlink terms such as 'bug 42' to an external bug tracker,
-supplying the argument record number '42' for display.  The third
-configuration 'tracker' uses raw HTML to more precisely control
-how the replacement is displayed to the user.
+supplying the argument record number '42' for display.
 
 commentlinks supports link:#reloadConfig[configuration reloads]. Though a
 link:cmd-flush-caches.html[flush-caches] of "projects" is needed for the
@@ -1686,18 +1740,15 @@
   link = "#/q/$1"
 
 [commentlink "bugzilla"]
-  match = "(bug\\s+#?)(\\d+)"
-  link = http://bugs.example.com/show_bug.cgi?id=$2
-
-[commentlink "tracker"]
-  match = ([Bb]ug:\\s+)(\\d+)
-  html = $1<a href=\"http://trak.example.com/$2\">$2</a>
+  match = "(^|\\s)(bug\\s+#?)(\\d+)($|\\s)"
+  link = http://bugs.example.com/show_bug.cgi?id=$3
+  prefix = $1
+  suffix = $4
+  text = $2$3
 ----
 
 Comment links can also be specified in `project.config` and sections in
-children override those in parents. The only restriction is that to
-avoid injecting arbitrary user-supplied HTML in the page, comment links
-defined in `project.config` may only supply `link`, not `html`.
+children override those in parents.
 
 [[commentlink.name.match]]commentlink.<name>.match::
 +
@@ -1722,22 +1773,32 @@
 be updated to match text formats.
 +
 A common pattern to match is `bug\\s+(\\d+)`.
++
+In order to better control the visual presentation of the link `prefix`,
+`suffix` and `text` is used. With the generated link html looking like:
+`prefix<a ...>text</a>suffix`.
 
 [[commentlink.name.link]]commentlink.<name>.link::
 +
 The URL to direct the user to whenever the regular expression is
 matched.  Groups in the match expression may be accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
 
-[[commentlink.name.html]]commentlink.<name>.html::
+[[commentlink.name.prefix]]commentlink.<name>.prefix::
 +
-HTML to replace the entire matched string with.  If present,
-this property overrides the link property above.  Groups in the
-match expression may be accessed as `$'n'`.
+The text inserted before the link. Groups in the match expression may be
+accessed as `$'n'`.
+
+[[commentlink.name.suffix]]commentlink.<name>.suffix::
 +
-The configuration file eats double quotes, so escaping them as
-`\"` is necessary to protect them from the parser.
+The text inserted after the link. Groups in the match expression may be
+accessed as `$'n'`.
+
+[[commentlink.name.text]]commentlink.<name>.text::
++
+The text content of the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+If not specified defaults to `$&` (the matched text).
 
 [[commentlink.name.enabled]]commentlink.<name>.enabled::
 +
@@ -1745,11 +1806,6 @@
 section in a parent or the site-wide config that is disabled by
 specifying `enabled = true`.
 +
-Disabling sections in `gerrit.config` can be used by site administrators
-to create a library of comment links with `html` set that are not
-user-supplied and thus can be verified to be XSS-free, but are only
-enabled for a subset of projects.
-+
 By default, true.
 +
 Note that the names and contents of disabled sections are visible even
@@ -1997,6 +2053,14 @@
 +
 Default is 1 hour.
 
+[[dashboard]]
+=== Section dashboard
+
+[[dashboard.submitRequirementColumns]]dashboard.submitRequirementColumns::
++
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard.
+
 [[download]]
 === Section download
 
@@ -2072,7 +2136,7 @@
 Gerrit advertises patch set downloads with the `repo download`
 command, assuming that all projects managed by this instance are
 generally worked on with the
-[repo multi-repository tool](https://gerrit.googlesource.com/git-repo).
+https://gerrit.googlesource.com/git-repo[repo multi-repository tool].
 This is not default, as not all instances will deploy repo.
 
 +
@@ -2183,6 +2247,9 @@
 other project managed by the running server. The name is
 relative to `gerrit.basePath`.
 +
+The link:#cache_names[persisted_projects cache] must be
+flushed after this setting is changed.
++
 Defaults to `All-Projects` if not set.
 
 [[gerrit.defaultBranch]]gerrit.defaultBranch::
@@ -2207,7 +2274,7 @@
 or "http://example.com:8080/gerrit/" so Gerrit can output links that point
 back to itself.
 +
-Setting this is highly recommended, as its necessary for the upload
+Setting this is highly recommended, as it is necessary for the upload
 code invoked by "git push" or "repo upload" to output hyperlinks
 to the newly uploaded changes.
 
@@ -2267,7 +2334,7 @@
 By default unset, as the HTTP daemon must be configured externally
 by the system administrator, and might not even be running on the
 same host as Gerrit.
-+
+
 [[gerrit.installBatchModule]]gerrit.installBatchModule::
 +
 Repeatable list of class name of additional Guice modules to load as
@@ -2277,7 +2344,7 @@
 located under the `/lib` directory.
 +
 By default unset.
-+
+
 [[gerrit.installDbModule]]gerrit.installDbModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -2288,6 +2355,25 @@
 +
 By default unset.
 
+[[gerrit.installIndexModule]]gerrit.installIndexModule::
++
+Class name of the Guice modules to load as alternate implementation
+for the Gerrit indexes backend.
+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.
++
+NOTE: The `gerrit.installIndexModule` has precedence over the
+`index.type`.
++
+By default unset.
++
+Example:
+----
+[gerrit]
+  installIndexModule = com.google.gerrit.elasticsearch.ElasticIndexModule
+----
++
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -2443,6 +2529,26 @@
 by the target version of the upgrade. Refer to the release notes and check whether
 the rolling upgrade is possible or not and the associated constraints.
 
+[[gerrit.importedServerId]]gerrit.importedServerId::
++
+ServerId of the repositories imported from other Gerrit servers. Changes coming
+associated with the imported serverIds are indexed and displayed in the UI.
++
+Specify multiple `gerrit.importedServerId` for allowing the import from multiple
+Gerrit servers with different serverIds.
++
+[NOTE]
+The account-ids referenced in the imported changes are used for looking up the
+associated account-id locally, using the `imported:` external-id.
+Example: the account-id 1000 from the imported server-id 59a4964e-6376-4ed9-beef
+will be looked up in the local accounts using the `imported:1000@59a4964e-6376-4ed9-beef`
+external-id.
++
+If this value is not set, all changes imported from other Gerrit servers will be
+ignored.
++
+By default empty.
+
 [[gerrit.serverId]]gerrit.serverId::
 +
 Used by NoteDb to, amongst other things, identify author identities from
@@ -2538,8 +2644,9 @@
 set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit,
-`${file}` for the file name and `${commit}` for the SHA-1 hash for
-the commit.
+`${file}` for the file name, `${hash}` for the SHA-1 hash for the commit,
+and `${commit}` for the change ref or SHA-1 of the commit if no base
+patch set.
 
 [[gitweb.filehistory]]gitweb.filehistory::
 +
@@ -2633,6 +2740,39 @@
 when this parameter is removed and the system group uses the default
 name again.
 
+[[groups.relevantGroup]]groups.relevantGroup::
++
+UUID of an external group that should always be considered as relevant
+when checking whether an account is visible.
++
+This setting is only relevant for external group backends and only if
+the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP`.
++
+If the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP` users should see all accounts that are
+a member of a group that contains themselves or that is visible to
+them. Checking this would require getting all groups of the current
+user and all groups of the accounts for which the visibility is being
+checked, but since getting all groups that a user is a member of is
+expensive for external group backends Gerrit doesn't query these groups
+but instead guesses the relevant groups. Guessing relevant groups
+limits the inspected groups to all groups that are mentioned in the
+ACLs of the projects that are currently cached (i.e. all groups that
+are listed in the link:config-project-config.html#file-groups[groups]
+files of the cached projects). This is not very reliable since it
+depends on which groups are mentioned in the ACLs and which projects
+are currently cached. To make this more reliable this configuration
+parameter allows to configure external groups that should always be
+considered as relevant.
++
+As said this setting is only relevant for external group backends. In
+Gerrit core this is only the LDAP backend, but it may apply to further
+group backends that are added by plugins.
++
+This parameter may be added multiple times to specify multiple relevant
+groups.
+
 [[has-operand-alias]]
 === Section has operand alias
 
@@ -3107,23 +3247,13 @@
 
 [[index.type]]index.type::
 +
-Type of secondary indexing employed by Gerrit.  The supported
-values are:
+*(DEPRECATED)* The only supported value is `LUCENE`, which is the default,
+that means a link:http://lucene.apache.org/[Lucene]
+index is used.
 +
-* `LUCENE`
+For using other indexing backends (e.g. ElasticSearch), refer to
+`gerrit.installIndexModule` setting.
 +
-A link:http://lucene.apache.org/[Lucene] index is used.
-+
-+
-* `ELASTICSEARCH` look into link:#elasticsearch[Elasticsearch section]
-+
-An link:https://www.elastic.co/products/elasticsearch[Elasticsearch,role=external,window=_blank] index is
-used. Refer to the link:#elasticsearch[Elasticsearch section] for further
-configuration details.
-
-+
-By default, `LUCENE`.
-
 [[index.threads]]index.threads::
 +
 Number of threads to use for indexing in normal interactive operations. Setting
@@ -3136,11 +3266,20 @@
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
-online schema upgrades, and also for offline reindexing.
+online schema upgrades, and also the default for offline reindexing.
 +
 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.cacheQueryResultsByChangeNum]]index.cacheQueryResultsByChangeNum::
++
+Allow to cache and reuse the change JSON elements by their Change number.
+This improves the performance of queries that are returning Changes duplicates.
+It needs to be turned off when having Changes imported from other servers
+because of the potential conflicts of change numbers.
++
+Defaults to true.
+
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
 Whether to upgrade to new index schema versions while the server is
@@ -3153,17 +3292,31 @@
 +
 Defaults to true.
 
+[[index.paginationType]]index.paginationType::
++
+The pagination type to use when index queries are repeated to
+obtain the next set of results. Supported values are:
++
+* `OFFSET`
++
+Index queries are repeated with a non-zero offset to obtain the
+next set of results.
++
+* `SEARCH_AFTER`
++
+Index queries are repeated using a search-after object. Index
+backends can provide their custom implementations for search-after.
+Note that, `SEARCH_AFTER` does not impact using offsets in Gerrit
+query APIs.
++
+Defaults to `OFFSET`.
+
 [[index.maxLimit]]index.maxLimit::
 +
 Maximum limit to allow for search queries. Requesting results above this
 limit will truncate the list (but will still set `_more_changes` on
 result lists). Set to 0 for no limit.
 +
-When `index.type` is set to `ELASTICSEARCH`, this value should not exceed
-the `index.max_result_window` value configured on the Elasticsearch
-server. If a value is not configured during site initialization, defaults to
-10000, which is the default value of `index.max_result_window` in Elasticsearch.
-+
 When `index.type` is set to `LUCENE`, defaults to no limit.
 
 [[index.maxPages]]index.maxPages::
@@ -3176,6 +3329,44 @@
 +
 Defaults to no limit.
 
+[[index.pageSizeMultiplier]]index.pageSizeMultiplier::
++
+When index queries are repeated to obtain more results, this multiplier
+will be used to determine the limit for the next query. Using a page
+multiplier allows queries to start off small and thus provide good
+latency for queries which may end up only having very few results, and
+then scaling up to have better throughput to handle queries with larger
+result sets without incurring the overhead of making as many queries as
+would be required with a smaller limit. This strategy of using a multiplier
+attempts to create a balance between latency and throughput by dynamically
+adjusting the query size to the number of results being returned by each
+query in the pagination.
++
+The larger the multiplier, the better the throughput on large queries, and
+it also improves latency on large queries by scaling up quickly. However, a
+larger multiplier can hurt latencies a bit by making the "last" query in a
+series longer than needed. The impact of this depends on how much the backend
+latency goes up when specifying a large limit and few results are returned.
+Setting link:#index.maxPageSize[index.maxPageSize] that isn't too large, can
+likely help reduce the impacts of this.
++
+For example, if the limit of the previous query was 500 and pageSizeMultiplier
+is configured to 5, the next query will have a limit of 2500.
++
+Defaults to 1 which effectively turns this feature off.
+
+[[index.maxPageSize]]index.maxPageSize::
++
+Maximum size to allow when index queries are repeated to obtain more results. Note
+that, link:#index.maxLimit[index.maxLimit] will be used to limit page size if it
+is configured to a value lower than maxPageSize.
++
+For example, if the limit of previous query was 500, pageSizeMultiplier is
+configured to 5 and maxPageSize to 2000, the next query will have a limit of
+2000 (instead of 2500).
++
+Defaults to no limit.
+
 [[index.maxTerms]]index.maxTerms::
 +
 Maximum number of leaf terms to allow in a query. Too-large queries may
@@ -3232,7 +3423,7 @@
 +
 Whether the scheduled indexer is enabled. If the scheduled indexer is
 disabled you must implement other means to keep the group index for the
-replica up-to-date (e.g. by using ElasticSearch for the indexes).
+replica up-to-date.
 +
 Defaults to `true`.
 
@@ -3339,6 +3530,11 @@
 +
 Defaults to true (throttling enabled).
 
+During offline reindexing, setting ramBufferSize greater than the size
+of index (size of specific index folder under <site_dir>/index) and
+maxBufferedDocs as -1 avoids unnecessary flushes and triggers only a
+single flush at the end of the process.
+
 Sample Lucene index configuration:
 ----
 [index]
@@ -3360,95 +3556,6 @@
 
 ----
 
-[[elasticsearch]]
-=== Section elasticsearch
-
-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,role=external,window=_blank].
-
-Note that when Gerrit is configured to use Elasticsearch, the Elasticsearch
-server(s) must be reachable during the site initialization.
-
-[[elasticsearch.prefix]]elasticsearch.prefix::
-+
-This setting can be used to prefix index names to allow multiple Gerrit
-instances in a single Elasticsearch cluster. Prefix `gerrit1_` would result in a
-change index named `gerrit1_changes_0001`.
-+
-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/index-modules.html#_static_index_settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 1.
-
-[[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/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 1.
-
-[[elasticsearch.maxResultWindow]]elasticsearch.maxResultWindow::
-+
-Sets the maximum value of `from + size` for searches to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 10000.
-
-[[elasticsearch.connectTimeout]]elasticsearch.connectTimeout::
-+
-Sets the timeout for connecting to elasticsearch.
-+
-Defaults to `1 second`.
-
-[[elasticsearch.socketTimeout]]elasticsearch.socketTimeout::
-+
-Sets the timeout for the underlying connection. For more information, refer to
-link:#httpd.idleTimeout[`httpd.idleTimeout`].
-+
-Defaults to `30 seconds`.
-
-==== 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
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/security-getting-started.html[the documentation,role=external,window=_blank].
-This is the current documentation link. Select another Elasticsearch version
-from the dropdown menu available on that page if need be.
-
-[[elasticsearch.username]]elasticsearch.username::
-+
-Username used to connect to Elasticsearch.
-+
-If a password is set, defaults to `elastic`, otherwise not set by default.
-
-[[elasticsearch.password]]elasticsearch.password::
-+
-Password used to connect to Elasticsearch.
-+
-Not set by default.
-
 [[event]]
 === Section event
 
@@ -3973,6 +4080,46 @@
 +
 Defaults to true.
 
+[[metrics]]
+=== Section metrics
+
+[[metrics.reservoir]]metrics.reservoir::
++
+The type of data reservoir used by the metrics system to calculate the percentile
+values for timers and histograms.
+It can be set to one of the following values:
++
+* ExponentiallyDecaying: An exponentially-decaying random reservoir based on
+  Cormode et al's forward-decaying priority reservoir sampling method to produce
+  a statistically representative sampling reservoir, exponentially biased towards
+  newer entries.
+* SlidingTimeWindowArray: A sliding window that stores only the measurements made
+  in the last window using chunks of 512 samples.
+* SlidingTimeWindow: A sliding window that stores only the measurements made in
+  the last window using a skip list.
+* SlidingWindow: A sliding window that stores only the last measurements.
+* Uniform: A random sampling reservoir that uses Vitter's Algorithm R to produce
+  a statistically representative sample.
++
+Defaults to ExponentiallyDecaying.
+
+[[metrics.ExponentiallyDecaying.alpha]]metrics.ExponentiallyDecaying.alpha::
++
+The exponential decay factor; the higher this is, the more biased the reservoir
+will be towards newer values.
+
+[[metrics.reservoirType.size]]metrics.<reservoirType>.size::
++
+The number of samples to keep in the reservoir. Applies to all reservoir types
+except the sliding time-based ones.
++
+Defaults to 1028.
+
+[[metrics.reservoirType.window]]metrics.<reservoirType>.window::
++
+The window of time for keeping data in the reservoir. It only applies to sliding
+time-based reservoir types.
+
 [[mimetype]]
 === Section mimetype
 
@@ -4156,6 +4303,14 @@
 +
 Default is 5 seconds. Negative values will be converted to 0.
 
+[[plugins.transitionalPushOptions]]plugins.transitionalPushOptions::
++
+Additional push options which should be accepted by gerrit as valid
+options even if they are not registered by any plugin(e.g. "myplugin~foo").
++
+This config can be used when gerrit migrates from a deprecated plugin to the new one. The new plugin
+can (temporary) accept push options of the old plugin without registering such options.
+
 [[receive]]
 === Section receive
 
@@ -4194,18 +4349,6 @@
 +
 Default is 5 minutes.
 
-[[receive.changeUpdateThreads]]receive.changeUpdateThreads::
-+
-Number of threads to perform change creation or patch set updates
-concurrently. Each thread uses its own database connection from
-the database connection pool, and if all threads are busy then
-main receive thread will also perform a change creation or patch
-set update.
-+
-Defaults to 1, using only the main receive thread. This feature is for
-databases with very high latency that can benefit from concurrent
-operations when multiple changes are impacted at once.
-
 [[receive.checkMagicRefs]]receive.checkMagicRefs::
 +
 If true, Gerrit will verify the destination repository has
@@ -4447,9 +4590,8 @@
 
 [[repository.name.ownerGroup]]repository.<name>.ownerGroup::
 +
-A name of a group which exists in the database. Zero, one or many
-groups are allowed.  Each on its own line.  Groups which don't exist
-in the database are ignored.
+A name of a link:config-groups.html[group] which exists. Zero, one or many
+groups are allowed.  Each on its own line.  Groups which don't exist are ignored.
 
 [[retry]]
 === Section retry
@@ -4902,16 +5044,6 @@
   replicate = replication start
 ----
 
-[[ssh]]
-=== Section ssh
-
-[[ssh.clientImplementation]]ssh.clientImplementation::
-+
-JCraft JSch client is supported in addition to Apache MINA SSH client.
-To use JSch client set the value to `JSCH`.
-+
-By default, `APACHE`.
-
 [[sshd]]
 === Section sshd
 
@@ -5132,22 +5264,27 @@
 +
 Supported ciphers:
 +
-* `aes128-ctr`
-* `aes192-ctr`
-* `aes256-ctr`
 * `aes128-cbc`
+* `aes128-ctr`
+* `aes128-gcm@openssh.com`
 * `aes192-cbc`
+* `aes192-ctr`
 * `aes256-cbc`
-* `blowfish-cbc`
-* `3des-cbc`
+* `aes256-ctr`
+* `aes256-gcm@openssh.com`
 * `arcfour128`
 * `arcfour256`
+* `blowfish-cbc`
+* `chacha20-poly1305@openssh.com`
+* `3des-cbc`
 * `none`
 +
-By default, all supported ciphers except `none` are available.
-+
 If your setup allows for it, it's recommended to disable all ciphers except
 the AES-CTR modes.
++
+See also link:https://github.com/apache/mina-sshd/tree/master#ciphers[ciphers,role=external,window=_blank].
++
+By default, all supported ciphers except `none` are available.
 
 [[sshd.mac]]sshd.mac::
 +
@@ -5165,6 +5302,11 @@
 * `hmac-sha1-96`
 * `hmac-sha2-256`
 * `hmac-sha2-512`
+* `hmac-sha1-etm@openssh.com`
+* `hmac-sha2-256-etm@openssh.com`
+* `hmac-sha2-512-etm@openssh.com`
++
+See also link:https://github.com/apache/mina-sshd/tree/master#macs[macs,role=external,window=_blank].
 +
 By default, all supported MACs are available.
 
@@ -5193,6 +5335,9 @@
 * `ecdh-sha2-nistp521`
 * `ecdh-sha2-nistp384`
 * `ecdh-sha2-nistp256`
+* `curve25519-sha256`
+* `curve25519-sha256@libssh.org`
+* `curve448-sha512`
 * `diffie-hellman-group-exchange-sha256`
 * `diffie-hellman-group18-sha512`
 * `diffie-hellman-group17-sha512`
@@ -5203,12 +5348,14 @@
 See link:#sshd.enableDeprecatedKexAlgorithms[sshd.enableDeprecatedKexAlgorithms]
 for deprecated key algorithms and how to enable them.
 
-By default, all supported key exchange algorithms are available.
-
 It is strongly recommended to disable at least `diffie-hellman-group1-sha1`
 as it's known to be vulnerable (logjam attack). Additionally, if your setup
 allows for it, it is recommended to disable the remaining two `sha1` key
 exchange algorithms.
+
+See also link:https://github.com/apache/mina-sshd/tree/master#key-exchange[key exchange,role=external,window=_blank].
+
+By default, all supported key exchange algorithms are available.
 --
 
 [[sshd.kerberosKeytab]]sshd.kerberosKeytab::
@@ -5288,6 +5435,7 @@
 used for suggesting accounts when adding members to a group.
 +
 By default 0.
+
 [[suggest.relevantChanges]]suggest.relevantChanges::
 +
 When suggesting reviewers, we go over recent changes of the user, and
@@ -5319,13 +5467,25 @@
 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.
+requests can be markedly increased.
+In one recorded case the impact was an overall heap increase of 40%
+(using the metrics-reporter-graphite plugin), in other instances the
+heap increase wasn't nearly as dramatic and the impact is most likely
+dependent on which plugin is used.
 +
-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, false.
+
+[[tracing.exportPerformanceMetrics]]tracing.exportPerformanceMetrics::
 +
-By default, true.
+Whether to export performance metrics.
++
+Performace logged when link:#tracing.performanceLogging[`performanceLogging`] is
+enabled, can be exported as metrics.
++
+NOTE: Since the payload returned could be of tens of thousands metrics,
+assess the latency of the metrics endpoint before enabling this option.
++
+By default, false.
 
 [[tracing.traceid]]
 ==== Subsection tracing.<trace-id>
@@ -5526,7 +5686,7 @@
 
 [[trackingid.name.system]]trackingid.<name>.system::
 +
-The name of the external tracking system (maximum 10 characters).
+The name of the external tracking system (maximum 20 characters).
 It is possible to have several trackingid entries for the same
 tracking system.
 
@@ -5786,10 +5946,6 @@
 [auth]
   registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
 
-[database]
-  username = webuser
-  password = s3kr3t
-
 [ldap]
   password = l3tm3srch
 
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index 46c9ced..00e33a3 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -268,7 +268,22 @@
 
 cgit can be used by specifying `gitweb.type` to be 'cgit'.
 
-It is also possible to define custom patterns.
+It is also possible to define custom patterns. Gitea can be used
+with custom patterns for example:
+
+----
+  git config -f $site_path/etc/gerrit.config gitweb.type custom
+  git config -f $site_path/etc/gerrit.config gitweb.urlEncode false
+  git config -f $site_path/etc/gerrit.config gitweb.linkname gitea
+  git config -f $site_path/etc/gerrit.config gitweb.url https://gitea.example.org/
+  git config -f $site_path/etc/gerrit.config gitweb.branch ${project}/src/branch/${branch}
+  git config -f $site_path/etc/gerrit.config gitweb.file ${project}/src/commit/${hash}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.filehistory ${project}/commits/branch/${branch}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.project ${project}
+  git config -f $site_path/etc/gerrit.config gitweb.revision ${project}/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.roottree ${project}/src/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.tag ${project}/src/tag/${tag}
+----
 
 === SEE ALSO
 
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index 0917515..4abb223 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -34,7 +34,7 @@
 group, there is a ref, stored as a sharded UUID, e.g.
 
 ----
-  refs/groups/ef/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
+  refs/groups/de/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
 ----
 
 The ref points to commits holding files. The files are
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 5889c75..ce63295 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -6,7 +6,47 @@
 link:access-control.html#category_review_labels[access controls].  Gerrit
 comes pre-configured with the Code-Review label that can be granted to
 groups within projects, enabling functionality for that group's members.
+Project owners and admins might also need to configure rules which require
+labels to be voted before a change can be submittable. See the
+link:config-submit-requirements.html[submit requirements] documentation to
+configure such rules.
 
+[[sticky_votes]]
+== Sticky Votes
+
+Whether votes are sticky when a new patch set is created depends on the
+link:#label_copyCondition[copyCondition] of the label. If an approval
+matches the configured condition it is copied from the old current
+patch set to the new current patch set. Votes that are not copied to
+the new patch set, are called `outdated`.
+
+If votes get outdated due to pushing a new patch set the uploader is
+informed about this by a message in the git output. In addition,
+outdated votes are also listed in the email notification that is sent
+for the new patch set (unless this is disabled by a custom email
+template). Note, that the uploader only get this email notification if
+they have configured `Every Comment` for `Email notifications` in their
+user preferences. With any other email preference the email sender, the
+uploader in this case, is not included in the email recipients.
+
+If votes get outdated due to creating a new patch set the user of the
+removed vote is added to the attention set of the change, as they need
+to re-review the change and renew their vote.
+
+If a vote is applied on an outdated patch set (i.e. a patch set that is
+not the current patch set) the vote is copied forward to follow-up
+patch sets if possible. A newly added or updated vote on an outdated
+patch set is copied to follow-up patch sets if:
+
+* the vote is copyable (i.e. it matches the
+link:#label_copyCondition[copyCondition] of the label)
+* neither the follow-up patch set nor an intermediate patch set has a
+  non-copied vote or a deletion vote (vote with value `0`) that
+  overrides the copy vote
+
+If an approval on an outdated patch set is removed or updated to a
+value that is not copyable, existing copies of that approval on
+follow-up patch sets are removed.
 
 [[label_Code-Review]]
 == Label: Code-Review
@@ -18,7 +58,7 @@
 
 The range of values is:
 
-* -2 This shall not be merged
+* -2 This shall not be submitted
 +
 The code is so horribly incorrect/buggy/broken that it must not be
 submitted to this project, or to this branch.  This value is valid
@@ -28,7 +68,7 @@
 +
 *Any -2 blocks submit.*
 
-* -1 I would prefer this is not merged as is
+* -1 I would prefer this is not submitted as is
 +
 The code doesn't look right, or could be done differently, but
 the reviewer is willing to live with it as-is if another reviewer
@@ -61,8 +101,8 @@
 
 For a change to be submittable, the latest patch set must have a
 `+2 Looks good to me, approved` in this category, and no
-`-2 Do not submit`.  Thus `-2` on any patch set can block a submit,
-while `+2` on the latest patch set can enable it.
+`-2 This shall not be submitted`.  Thus `-2` on any patch set can
+block a submit, while `+2` on the latest patch set can enable it.
 
 If a Gerrit installation does not wish to use this label in any project,
 the `[label "Code-Review"]` section can be deleted from `project.config`
@@ -97,7 +137,7 @@
       value = -1 Fails
       value = 0 No score
       value = +1 Verified
-      copyAllScoresIfNoCodeChange = true
+      copyCondition = changekind:NO_CODE_CHANGE
 ----
 
 The range of values is:
@@ -160,9 +200,9 @@
 properties in the child project's configuration; all properties from
 the parent definition must be redefined in the child.
 
-To remove a label in a child project, add an empty label with the same
-name as in the parent. This will override the parent label with
-a label containing the defaults (`function = MaxWithBlock`,
+To remove a label in a child project, add an empty label with a single "0"
+value, with the same name as in the parent. This will override the parent label
+with a label containing the defaults (`function = NoBlock`,
 `defaultValue = 0` and no further allowed values)
 
 [[label_layout]]
@@ -176,6 +216,11 @@
 The name for a label, consisting only of alphanumeric characters and
 `-`.
 
+[[label_description]]
+=== `label.Label-Name.description`
+
+The label description. This field can provide extra information of what the
+label is supposed to do.
 
 [[label_value]]
 === `label.Label-Name.value`
@@ -200,7 +245,21 @@
 
 
 [[label_function]]
-=== `label.Label-Name.function`
+=== `label.Label-Name.function (deprecated)`
+
+Label functions dictate the rules for requiring certain label votes before a
+change is allowed for submission. Label functions are **deprecated** and updates
+that set `function` to a blocking value {`MaxWithBlock`, `MaxNoBlock`,
+`AnyWithBlock`} will be rejected. Existing label function definitions can only
+be updated to {`NoBlock`, `NoOp`, `PatchSetLock`}. New label defintions should
+also explicitly set the `function` attribute to a non-blocking value since the
+default is `MaxWithBlock`.
+
+If your project has a
+blocking label function, we highly encourage you to change it to `NoBlock` and
+add a submit-requirement for the same label. See the
+link:config-submit-requirements.html#code-review-example[submit-requirements
+documentation] for more details.
 
 The name of a function for evaluating multiple votes for a label.  This
 function is only applied if the default submit rule is used for a label.
@@ -262,12 +321,6 @@
 
 Defaults to true.
 
-[[label_copyAnyScore]]
-=== `label.Label-Name.copyAnyScore`
-
-If true, any score for the label is copied forward when a new patch
-set is uploaded. Defaults to false.
-
 [[label_copyCondition]]
 === `label.Label-Name.copyCondition`
 
@@ -280,39 +333,164 @@
 
 Gerrit currently supports the following predicates:
 
-==== changekind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+[[changekind]]
+==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE}
 
-Matches if the diff between two patch sets was of a certain change kind.
+Matches if the diff between two patch sets was of a certain change kind:
 
+* [[no_change]]`NO_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree,
+code delta, and commit message as the previous patch set. This means
+that only the patch set SHA-1 is different. This can be used to enable
+sticky approvals, reducing turn-around for this special case.
++
+It is recommended to leave this enabled for both, the Code-Review and
+the Verified labels.
++
+`NO_CHANGE` is more trivial than a trivial rebase, no code change and
+a first parent update, hence this change kind is also matched by
+`changekind:TRIVIAL_REBASE`, `changekind:NO_CODE_CHANGE` and if it's
+a merge commit by `changekind:MERGE_FIRST_PARENT_UPDATE`.
+
+
+* [[no_code_change]]`NO_CODE_CHANGE`:
++
+Matches 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
+may be different; the change hasn't even been rebased. Also matches if
+the commit message is not different, which means this includes matching
+patch sets that have `NO_CHANGE` as the change kind.
++
+This predicate 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 predicate is used by
+default.
+
+* [[merge_first_parent_update]]`MERGE_FIRST_PARENT_UPDATE`:
++
+Matches when a new patch set is uploaded that is a new merge commit
+which only differs from the merge commit in the previous patch set in
+its first parent, or has identical parents (aka the change kind of the
+merge commit is `NO_CHANGE`).
++
+The first parent of the merge commit is part of the change's target
+branch, whereas the other parent(s) refer to the feature branch(es) to
+be merged.
++
+Matching this change kind is useful if you don't want to trigger CI or
+human verification again if your target branch moved on but the feature
+branch(es) being merged into the target branch did not change.
++
+This predicate does not match if the patch set is not a merge commit.
+
+* [[trivial_rebase]]`TRIVIAL_REBASE`:
++
+Matches 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 (aka the change kind of the commit is
+`NO_CHANGE`).
++
+This predicate can be used to enable sticky approvals, reducing
+turn-around for trivial rebases prior to submitting a change.
++
+For the pre-installed Code-Review label this predicate is used by
+default.
+
+* [[rework]]`REWORK`:
++
+Matches all kind of change kinds because any other change kind
+is just a more trivial version of a rework. This means setting
+`changekind:REWORK` is equivalent to setting `is:ANY`.
+
+[[is_magic]]
 ==== is:{MIN,MAX,ANY}
 
-Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+Matches approvals that have a minimal, maximal or any score:
 
-==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
+* [[is_min]]`MIN`:
++
+Matches approvals that have a minimal score, i.e. the lowest possible
+(negative) value for this label.
+
+* [[is_max]]`MAX`:
++
+Matches approvals that a maximal score, i.e. the highest possible
+(positive) value for this label.
+
+* [[is_any]]`ANY`:
++
+Matches any approval when a new patch set is uploaded.
+
+[[is_value]]
+==== is:'VALUE'
+
+Matches approvals that have a voting value that is equal to 'VALUE'.
+
+Negative values need to be quoted, e.g.: is:"-1"
+
+[[approverin]]
+==== approverin:link:#group-id[\{group-id\}]
 
 Matches votes granted by a user who is a member of
-link:rest-api-groups.html#group-id[\{group-id\}].
+link:#group-id[\{group-id\}].
 
-Avoid using a group name with spaces (if it has spaces, use the group uuid).
-Although supported for convenience, it's better to use group uuid than group
-name since using names only works as long as the names are unique (and future
-groups with the same name will break the query).
+[[uploaderin]]
+==== uploaderin:link:#group-id[\{group-id\}]
 
-==== uploaderin:link:rest-api-groups.html#group-id[\{group-id\}]
+Matches all votes if the new patch set was uploaded by a member of
+link:#group-id[\{group-id\}].
 
-Matches votes where the new patch set was uploaded by a member of
-link:rest-api-groups.html#group-id[\{group-id\}].
-
-Avoid using a group name with spaces (if it has spaces, use the group uuid).
-Although supported for convenience, it's better to use group uuid than group
-name since using names only works as long as the names are unique (and future
-groups with the same name will break the query).
-
+[[has_unchanged_files]]
 ==== has:unchanged-files
 
-Matches when the new patch-set includes the same files as the old patch-set.
+Matches when the new patch-set has the same list of files as the
+previous patch-set.
 
-Only 'unchanged-files' is supported for 'has'.
+Votes are not copied in the following cases:
+
+  * If one more files are renamed in the new patch set. These files are counted
+  as a deletion of the file at the old path and an addition of the file at the
+  new path. This means the list of files did change.
+  * If one or more files are reverted to their original content, that is files
+  that become same as in the base revision.
+
+This predicate is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+Note, "unchanged-files" is the only value that is supported for the
+"has" operator.
+
+[[group-id]]
+==== Group ID
+
+Some predicates (link:#approverin[approverin], link:#uploaderin[uploaderin])
+expect a group ID as value. This group ID can be any of the
+link:rest-api-groups.html#group-id[group identifiers] that are supported in the
+REST API: group UUID, group ID (for Gerrit internal groups only) and group name
+
+It's preferred to reference groups by UUID, rather than name. Referencing
+groups by name is not recommended because:
+
+* Groups may be renamed and then the group reference can no longer be resolved.
+  If this happens another group with different members can take over the group
+  name, so that exemptions which have been granted by this predicate apply to
+  the other group. This is a security concern.
+* Group names that contain spaces are not supported.
+* Ambiguous group names cannot be resolved. This means if another group with
+  the same name gets created at a later point in time, the group name can no
+  longer be resolved and the predicate breaks.
+
+Using the group UUID has a small drawback though, since it makes the condition
+less human-readable.
 
 ==== Example
 
@@ -320,105 +498,6 @@
 copyCondition = is:MIN OR -change-kind:REWORK OR uploaderin:dead...beef
 ----
 
-[[label_copyMinScore]]
-=== `label.Label-Name.copyMinScore`
-
-If true, the lowest possible negative value for the label is copied
-forward when a new patch set is uploaded. Defaults to false, except
-for All-Projects which has it true by default.
-
-[[label_copyMaxScore]]
-=== `label.Label-Name.copyMaxScore`
-
-If true, the highest possible positive value for the label is copied
-forward when a new patch set is uploaded. This can be used to enable
-sticky approvals, reducing turn-around for trivial cleanups prior to
-submitting a change. Defaults to false.
-
-[[label_copyAllScoresIfListOfFilesDidNotChange]]
-=== `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
-
-This policy is useful if you don't want to trigger CI or human
-verification again if the list of files didn't change.
-
-If true, all scores for the label are copied forward when a new
-patch-set is uploaded that has the same list of files as the previous
-patch-set.
-
-Renames are considered different files when computing whether new files
-were added or old files were deleted. Hence, if there are renames, scores will
-*NOT* be copied over.
-
-Defaults to false.
-
-[[label_copyAllScoresOnMergeFirstParentUpdate]]
-=== `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
-
-This policy is useful if you don't want to trigger CI or human
-verification again if your target branch moved on but the feature
-branch being merged into the target branch did not change. It only
-applies if the patch set is a merge commit.
-
-If true, all scores for the label are copied forward when a new
-patch set is uploaded that is a new merge commit which only
-differs from the previous patch set in its first parent, or has
-identical parents. The first parent would be the parent of the merge
-commit that is part of the change's target branch, whereas the other
-parent(s) refer to the feature branch(es) to be merged.
-
-Defaults to false.
-
-[[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 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.
-For the pre-installed Code-Review label this is enabled by default.
-
-Defaults to false.
-
-[[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 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.
-
-[[label_copyAllScoresIfNoChange]]
-=== `label.Label-Name.copyAllScoresIfNoChange`
-
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that has the same parent tree, code delta, and commit
-message as the previous patch set. This means that only the patch
-set SHA-1 is different. This can be used to enable sticky
-approvals, reducing turn-around for this special case.
-It is recommended to leave this enabled for both Verified and
-Code-Review labels.
-
-Defaults to true.
-
-[[label_copyValue]]
-=== `label.Label-Name.copyValue`
-
-Value that should be copied forward when a new patch set is uploaded.
-This can be used to enable sticky votes. Can be specified multiple
-times. By default not set.
-
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
 
@@ -457,7 +536,7 @@
 `${shardeduserid}`.
 
 [[label_ignoreSelfApproval]]
-=== `label.Label-Name.ignoreSelfApproval`
+=== `label.Label-Name.ignoreSelfApproval (deprecated)`
 
 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
@@ -465,6 +544,13 @@
 
 Defaults to false.
 
+The `ignoreSelfApproval` attribute is **deprecated**, favour
+using link:config-submit-requirements.html[submit requirements] and
+define the `submittableIf` expression with the `label` operator and
+the `user=non_uploader` argument. See the
+link:config-submit-requirements.html#code-review-example[Code Review] submit
+requirement example.
+
 [[label_example]]
 === Example
 
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 85006dc..8bd5dc7 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -20,6 +20,11 @@
 example template to an equivalently named file without the `.example` extension
 and modifying it will allow an administrator to customize the template.
 
+[NOTE]
+The content of the templates at `'$site_path'/etc/mail/.*\.soy` are cached at
+startup by Gerrit. If they are modified Gerrit needs to be restarted before the
+changes takes effect.
+
 == Supported Mail Templates
 
 Each mail that Gerrit sends out is controlled by at least one template.  These
@@ -221,6 +226,10 @@
 The original subject limited to 72 characters, with an ellipsis if it exceeds
 that.
 
+$change.sizeBucket::
++
+Human-readable size bucket of the current change.
+
 $change.ownerEmail::
 +
 The email address of the owner of the change.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 4dff685..31008f6 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -9,6 +9,7 @@
 relevant in an automation scenario of the access controls.
 
 
+[[refs-meta-config]]
 == The +refs/meta/config+ namespace
 
 The namespace contains three different files that play different
@@ -142,7 +143,7 @@
 [[receive.requireContributorAgreement]]receive.requireContributorAgreement::
 +
 Controls whether or not a user must complete a contributor agreement before
-they can upload changes. Default is `INHERIT`. If `All-Project` enables this
+they can upload changes. Default is `INHERIT`. If `All-Projects` enables this
 option then the dependent project must set it to false if users are not
 required to sign a contributor agreement prior to submitting changes for that
 specific project. To use that feature the global option in `gerrit.config`
@@ -318,17 +319,306 @@
 
 [[content_merge]]submit.mergeContent::
 +
-Defines whether Gerrit will try to
-do a content merge when a path conflict occurs. Valid values are
-'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
-be modified by any project owner through the project console, `Browse`
-> `Repositories` > my/project > `Allow content merges`.
+Defines whether Gerrit will try to do a content merge when a path conflict
+occurs while submitting a change.
++
+A path conflict occurs when the same file has been changed on both sides of a
+merge, e.g. when the same file has been touched in a change and concurrently in
+the target branch.
++
+Doing a content merge means that Gerrit attempts to merge the conflicting file
+contents from both sides of the merge. This is successful if the touched lines
+(plus some surrounding context lines) do not overlap (i.e. both sides touch
+distinct lines).
++
+NOTE: The content merge setting is not relevant when
+link:#fast_forward_only[fast forward only] is configured as the
+link:#submit.action[submit action] because in this case Gerrit will never
+perform a merge, rebase or cherry-pick on submit.
++
+If content merges are disabled, the submit button in the Gerrit web UI is
+disabled, if any path conflict would occur on submitting the change. Users then
+need to rebase the change manually to resolve the path conflict and then get
+the change re-approved so that they can submit it.
++
+NOTE: If only distinct lines have been touched on both sides, rebasing the
+change from the Gerrit UI is sufficient to resolve the path conflict, since the
+rebase action always does the rebase with content merge enabled.
++
+The advantage of enabling content merges on submit is that it makes it less
+likely that change submissions are rejected due to conflicts. Each change
+submission that goes through with content merge, but would be rejected
+otherwise, saves the user from needing to do extra work to get the change
+submitted (rebase the change, get it re-approved and then submit it again).
++
+On the other hand, disabling content merges decreases the chance of breaking
+branches by submitting content merges of incompatible modifications in the same
+file, e.g. a function is removed on one side and a new usage of that function
+is added on the other side. Note, that the chance of breaking a branch by
+incompatible modifications is only reduced, but not eliminated, e.g. even with
+content merges disabled it's possible that a function is removed in one file
+and a new usage of that function is added in another file.
++
+The huge drawback of disabling content merge is that users need to do extra
+work when a change isn't submittable due to a path conflict which could be
+avoided if content merge was enabled (see above). In addition to this, it also
+confuses and frustrates users if a change submission is rejected by Gerrit due
+to a path conflict, but then when they rebase the change manually they do not
+see any conflict (because manual rebases are always done with content merge
+enabled).
++
+In general, the stability gain of disabling content merges is not worth the
+overhead and confusion that this adds for users, which is why disabling content
+merges is highly discouraged.
++
+Valid values are `true`, `false`, or `INHERIT`.
++
+The default is `INHERIT`.
++
+NOTE: Project owners can also set this option in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Allow content merges`.
 
 [[submit.action]]submit.action::
 +
-Defines the link:#submit-type[submit type].  Valid
-values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
-'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
+Defines the submit action aka submit type aka submit strategy that Gerrit
+should use to integrate changes into the target branch when they are submitted.
++
+In general, submitting a change only merges the change if all its dependencies
+are also submitted. The only exception is the `cherry pick` submit action which
+ignores dependencies and hence is not recommended to be used (see
+link:#cherry_pick[below]).
++
+[[submit-type]]
+--
+The following submit actions are supported:
+
+[[merge_if_necessary]]
+* 'merge if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise creates a merge commit automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically creates a merge commit
+that merges the current patch set of the change into the target branch and then
+the target branch is fast-forwarded to the merge commit.
++
+The behavior of this submit action is identical with the classical `git merge`
+behavior, or
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---ff[git
+merge --ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based.
+
+[[always_merge]]
+[[merge_always]]
+* 'merge always':
++
+With this action, when a change is being submitted, Gerrit always creates a
+merge commit, even if a fast-forward is possible.
++
+This is identical to the behavior of
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---no-ff[git merge --no-ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based. In
+addition, from the merge commits you can see when the changes were submitted
+and it's possible to follow submissions with `git log --first-parent`.
+
+[[rebase_if_necessary]]
+* 'rebase if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise does a rebase automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically rebases the current
+patch set of the change on top of the current head of the target branch and
+then the target branch is fast-forwarded to the rebased commit.
++
+With this submit action, when a rebase is performed, the original commits that
+have been reviewed and approved do not become part of the target branch's
+history. This means the information on when the original commits were committed
+and on which parent they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, 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 in the web UI (see link:#submit-footers[below]). If a fast-forward
+is done no footers are added.
+
+[[rebase_always]]
+* 'rebase always':
++
+With this action, when a change is being submitted, Gerrit always does a
+rebase, even if a fast-forward is possible.
++
+With this submit action, the original commits that have been reviewed and
+approved do not become part of the target branch's history. This means the
+information on when the original commits were committed and on which parent
+they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, 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 in the web UI (see link:#submit-footers[below]).
++
+The footers that are added are exactly the same footers that are also added by
+the link:cherry_pick[cherry pick] action. Thus, the `rebase always` action can
+be considered similar to the `cherry pick` action, but with the important
+distinction that `rebase always` does not ignore dependencies, which is why
+using the `rebase always` action should be preferred over the `cherry pick`
+submit action.
+
+[[fast_forward_only]]
+* 'fast forward only' (usage generally not recommended):
++
+With this action a change can only be submitted if at submit time the target
+branch can be fast-forwarded to the commit that represents the current patch
+set of the change. This means in order for a change to be submittable its
+current patch set must have the current head of the target branch in its parent
+lineage.
++
+The advantage of using this action is that the target branch is always updated
+to the exact commit that has been reviewed and approved. In particular, if CI
+verification is configured, this means that the CI verified the exact commit to
+which the target branch is being fast-forwarded on submit (assuming no approval
+copying is configured for CI votes).
++
+The huge drawback of using this action is that whenever one change is submitted
+all other open changes for the same branch, that are not successors of the
+submitted change, become non-submittable, since the target branch can no longer
+be fast-forwarded to their current patch sets. Making these changes submittable
+again requires rebasing and re-approving/re-verifying them. For most projects
+this causes an unreasonable amount of overhead that doesn't justify the
+stability gain by verifying exact commits so that using this submit action is
+generally discouraged. Using this action should only be considered for projects
+that have a low frequency of changes and that have special requirements to
+never break any target branch.
++
+NOTE: With this submit action Gerrit does not create merge commits on
+submitting a change, but merge commits that are created on the client, prior to
+uploading to Gerrit for review, may still be submitted.
+
+[[cherry_pick]]
+* 'cherry pick' (usage not recommended, use link:#rebase_always[rebase always]
+instead):
++
+With this submit action Gerrit always performs a cherry pick of the current
+patch set when a change is submitted. This ignores the parent lineage and
+instead creates a brand new commit on top of the current head of the target
+branch. The submitter becomes the committer of the new commit and the original
+commit author is retained.
++
+Ignoring change dependencies on submit is often confusing for users. For users
+that stack changes on top of each other, it's unexpected that these
+dependencies are ignored on submit. Ignoring dependencies also means that
+submitters need to submit the changes individually in the correct order.
+Otherwise it's likely that submissions fail due to conflicts or that the
+target branch gets broken (because it contains the submitted change, but not
+its predecessors which may be required for the submitted change to work
+correctly).
++
+If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+is enabled changes that have the same topic are submitted together, the same as
+with all other submit actions. This means by setting the same topic on all
+dependent changes it's possible to submit a stack of changes together and
+overcome the limitation that change dependencies are ignored.
++
+When cherry picking the patchset, 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 in the web UI (see link:#submit-footers[below]).
++
+Using this submit action is not recommended because it ignores change
+dependencies, instead link:#rebase_always[rebase always] should be used which
+behaves the same way except that it respects change dependencies (in particular
+`rebase always` adds the same kind of footers to the merged commit as
+`cherry pick`).
+
+--
++
+[[submit_type_inherit]]
+In addition the submit action can be set to `Inherit`, which means that the
+value that is configured in the parent project applies. For new projects
+`Inherit` is the default, unless the default is overridden by the global
+link:config-gerrit.html#repository.name.defaultSubmitType[defaultSubmitType]
+configuration. Configuring `Inherit` for the `All-Projects` root project is
+equivalent to configuring link:#merge_if_necessary[merge if necessary].
++
+If `submit.action` is not set, the default is 'merge if necessary'.
++
+NOTE: The different submit actions are also described in the
+link:https://docs.google.com/presentation/d/1C73UgQdzZDw0gzpaEqIC6SPujZJhqamyqO1XOHjH-uk/edit#slide=id.g4d6c16487b_1_800[Gerrit - Concepts and Workflows]
+presentation, where their behavior is visualized by git commit graphs.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it is controlled by the
+link:#submit.mergeContent[mergeContent] setting whether a content merge is
+performed when there is a path conflict.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it can be that trying to submit
+a change would fail due to Git conflicts (if the same lines were modified
+concurrently, or if link:#submit.mergeContent[mergeContent] is disabled also if
+the same files were modified concurrently). In this case the submit button in
+the Gerrit web UI is disabled and a tooltip on the disabled submit button
+informs about the change being non-mergeable.
++
+[[submit-footers]]
+--
+NOTE: If Gerrit performs a rebase or cherry-pick as part of the change
+submission (true for link:#rebase_if_necessary[rebase if necessary],
+link:#rebase_always[rebase always] and link:#cherry_pick[cherry pick]) Gerrit
+inserts additional footers into the commit message of the newly created
+commit: +
+ +
+* `Change-Id: <change-id>` (only if this footer is not already present, see
+  link:user-changeid.html[Change-Id]) +
+* `Reviewed-on: <change-url>` (links to the change in Gerrit where this commit
+  was reviewed) +
+* `Reviewed-by: <reviewer>` (once for every reviewer with a positive
+  `Code-Review` vote) +
+* `Tested-by: <reviewer>` (once for every reviewer with a positive `Verified`
+  vote) +
+* `<label-name>: <reviewer>` (once for every reviewer with a positive vote on
+  any other label) +
+ +
+In addition, plugins that implement a
+link:dev-plugins.html#change-message-modifier[Change Message Modifier] may add
+additional custom footers.
+--
++
+NOTE: For the value of `submit.action` in `project.config` use the exact
+spelling as given above, e.g. 'merge if necessary' (without the single quotes,
+but with the spaces).
++
+NOTE: Project owners can also set the submit action in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Submit type`
 
 [[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
 +
@@ -346,98 +636,6 @@
 is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
 initial commit on a branch.
 
-[[submit-type]]
-==== Submit Type
-
-'submit.action': The method Gerrit uses to submit a change to a project.
-
-The submit type can also be modified by any project owner through the
-project console, `Browse` > `Repositories` > my/project > 'Submit type'.
-In general, a submitting a change only merges the change 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.
-
 
 [[access-section]]
 === Access section
@@ -481,6 +679,12 @@
 
 Please refer to link:config-labels.html#label_custom[Custom Labels] documentation.
 
+[[submit-requirement-section]]
+=== Submit Requirement section
+
+Please refer to link:config-submit-requirements.html[Configuring Submit
+Requirements] documentation.
+
 [[branchOrder-section]]
 === branchOrder section
 
@@ -526,7 +730,16 @@
 A boolean indicating if reviewers and CCs that do not currently have a Gerrit
 account can be added to a change by providing their email address.
 +
-This setting only takes affect for changes that are readable by anonymous users.
+This setting only takes effect for changes that are readable by anonymous users.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
+
+[[reviewer.skipAddingAuthorAndCommitterAsReviewers]]reviewer.skipAddingAuthorAndCommitterAsReviewers::
++
+Whether to skip adding the Git commit author and committer as reviewers for
+a new change.
 +
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. If the property is not set in any parent project, the
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
new file mode 100644
index 0000000..fb37287
--- /dev/null
+++ b/Documentation/config-submit-requirements.txt
@@ -0,0 +1,347 @@
+= Gerrit Code Review - Submit Requirements
+
+As part of the code review process, project owners need to configure rules that
+govern when changes become submittable. For example, an admin might want to
+prevent changes from being submittable until at least a “+2” vote on the
+“Code-Review” label is granted on a change. Admins can define submit
+requirements to enforce submittability rules for changes.
+
+[[configuring_submit_requirements]]
+== Configuring Submit Requirements
+
+Site administrators and project owners can define submit requirements in the
+link:config-project-config.html[project config]. A submit requirement has the
+following fields:
+
+
+[[submit_requirement_name]]
+=== submit-requirement.Name
+
+A name that uniquely identifies the submit requirement. Submit requirements
+can be overridden in child projects if they are defined with the same name in
+the child project. See the link:#inheritance[inheritance] section for more
+details.
+
+[[submit_requirement_description]]
+=== submit-requirement.Name.description
+
+A detailed description of what the submit requirement is supposed to do. This
+field is optional. The description is visible to the users in the change page
+upon hovering on the submit requirement to help them understand what the
+requirement is about and how it can be fulfilled.
+
+[[submit_requirement_applicable_if]]
+=== submit-requirement.Name.applicableIf
+
+A link:#query_expression_syntax[query expression] that determines if the submit
+requirement is applicable for a change. For example, administrators can exclude
+submit requirements for certain branch patterns. See the
+link:#exempt-branch-example[exempt branch] example.
+
+Often submit requirements should only apply to branches that contain source
+code. In this case this parameter can be used to exclude the
+link:config-project-config.html#refs-meta-config[refs/meta/config] branch from
+a submit requirement:
+
+----
+  applicableIf = -branch:refs/meta/config
+----
+
+This field is optional, and if not specified, the submit requirement is
+considered applicable for all changes in the project.
+
+[[submit_requirement_submittable_if]]
+=== submit-requirement.Name.submittableIf
+
+A link:#query_expression_syntax[query expression] that determines when the
+change becomes submittable. This field is mandatory.
+
+
+[[submit_requirement_override_if]]
+=== submit-requirement.Name.overrideIf
+
+A link:#query_expression_syntax[query expression] that controls when the
+submit requirement is overridden. When this expression is evaluated to true,
+the submit requirement state becomes `OVERRIDDEN` and the submit requirement
+is no longer blocking the change submission.
+This expression can be used to enable bypassing the requirement in some
+circumstances, for example if the change owner is a power user or to allow
+change submission in case of emergencies. +
+
+This field is optional.
+
+[[submit_requirement_can_override_in_child_projects]]
+=== submit-requirement.Name.canOverrideInChildProjects
+
+A boolean (true, false) that determines if child projects can override the
+submit requirement. +
+
+The default value is `false`.
+
+[[evaluation_results]]
+== Evaluation Results
+
+When submit requirements are configured, their results are returned for all
+changes requested by the REST API with the
+link:rest-api-changes.html#submit-requirement-result-info[SubmitRequirementResultInfo]
+entity. +
+
+Submit requirement results are produced from the evaluation of the submit
+requirements in the project config (
+See link:#configuring_submit_requirements[Configuring Submit Requirements])
+as well as the conversion of the results of the legacy submit rules to submit
+requirement results. Legacy submit rules are label functions
+(see link:config-labels.html[config labels]), custom and
+link:prolog-cookbook.html[prolog] submit rules.
+
+The `status` field can be one of:
+
+* `NOT_APPLICABLE`
++
+The link:#submit_requirement_applicable_if[applicableIf] expression evaluates
+to false for the change.
+
+* `UNSATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), but
+the evaluation of the link:#submit_requirement_submittable_if[submittableIf] and
+link:#submit_requirement_override_if[overrideIf] expressions return false for
+the change.
+
+* `SATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), the
+link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
+true, and the link:#submit_requirement_override_if[overrideIf] evaluates to
+false for the change.
+
+* `OVERRIDDEN`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true) and the
+link:#submit_requirement_override_if[overrideIf] expression evaluates to true.
++
+Note that in this case, the change is overridden whether the
+link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
+true or not.
+
+* `BYPASSED`
++
+The change was merged directly bypassing code review by supplying the
+link:user-upload.html#auto_merge[submit] push option while doing a git push.
+
+* `ERROR`
++
+The evaluation of any of the
+link:#submit_requirement_applicable_if[applicableIf],
+link:#submit_requirement_submittable_if[submittableIf] or
+link:#submit_requirement_override_if[overrideIf] expressions resulted in an
+error.
+
+
+[[query_expression_syntax]]
+== Query Expression Syntax
+
+All applicableIf, submittableIf and overrideIf expressions use the same syntax
+and operators available for link:user-search.html[searching changes]. In
+addition to that, submit requirements support extra operators.
+
+
+[[submit_requirements_operators]]
+=== Submit Requirements Operators
+
+[[operator_authoremail]]
+authoremail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change author's email address matches a
+specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
+[[operator_distinctvoters]]
+distinctvoters:'[Label1,Label2,...,LabelN],value=MAX,count>1'::
++
+An operator that allows checking for distinct voters across more than one label.
++
+2..N labels are supported, filtering by a value (MIN,MAX,integer) is optional.
+Count is mandatory.
++
+Examples:
+`distinctvoters:[Code-Review,Trust],value=MAX,count>1`
++
+`distinctvoters:[Code-Review,Trust,API-Review],count>2`
+
+[[operator_is_true]]
+is:true::
++
+An operator that always returns true for all changes. An example usage is to
+redefine a submit requirement in a child project and make the submit requirement
+always applicable.
+
+[[operator_is_false]]
+is:false::
++
+An operator that always returns false for all changes. An example usage is to
+redefine a submit requirement in a child project and make the submit requirement
+always non-applicable.
+
+[[operator_has_submodule_update]]
+has:submodule-update::
++
+An operator that returns true if the diff of the latest patchset against the
+default parent has a submodule modified file, that is, a ".gitmodules" or a
+git link file.
++
+The optional `base` parameter can also be supplied for merge commits like
+`has:submodule-update,base=1`, or `has:submodule-update,base=2`. In these cases,
+the operator returns true if the diff of the latest patchset against parent
+number identified by `base` has a submodule modified file. Note that the
+operator will return false if the base parameter is greater than the number of
+parents for the latest patchset for the change.
+
+[[operator_file]]
+file:"'<filePattern>',withDiffContaining='<contentPattern>'"::
++
+An operator that returns true if the latest patchset contained a modified file
+matching `<filePattern>` with a modified region matching `<contentPattern>`.
+
+[[unsupported_operators]]
+=== Unsupported Operators
+
+Some operators are not supported with submit requirement expressions.
+
+[[operator_is_submittable]]
+is:submittable::
++
+Cannot be used since it will result in recursive evaluation of expressions.
+
+[[inheritance]]
+== Inheritance
+
+Child projects can override a submit requirement defined in any of their parent
+projects. Overriding a submit requirement overrides all of its properties and
+values. The overriding project needs to define all mandatory fields.
+
+Submit requirements are looked up from the current project up the inheritance
+hierarchy to “All-Projects”. The first project in the hierarchy chain that sets
+link:#submit_requirement_can_override_in_child_projects[canOverrideInChildProjects]
+to false prevents all descendant projects from overriding it.
+
+If a project disallows a submit requirement from being overridden in child
+projects, all definitions of this submit requirement in descendant projects are
+ignored.
+
+To remove a submit requirement in a child project, administrators can redefine
+the requirement with the same name in the child project and set the
+link:#submit_requirement_applicable_if[applicableIf] expression to `is:false`.
+Since the link:#submit_requirement_submittable_if[submittableIf] field is
+mandatory, administrators need to provide it in the child project but can set it
+to anything, for example `is:false` but it will have no effect anyway.
+
+
+[[trigger-votes]]
+== Trigger Votes
+
+Trigger votes are label votes that are not associated with any submit
+requirement expressions. Trigger votes are displayed in a separate section in
+the change page. For more about configuring labels, see the
+link:config-labels.html[config labels] documentation.
+
+
+[[examples]]
+== Examples
+
+[[code-review-example]]
+=== Code-Review Example
+
+To define a submit requirement for code-review that requires a maximum vote for
+the “Code-Review” label from a non-uploader without a maximum negative vote:
+
+----
+[submit-requirement "Code-Review"]
+	description = A maximum vote from a non-uploader is required for the \
+	              'Code-Review' label. A minimum vote is blocking.
+	submittableIf = label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN
+	canOverrideInChildProjects = true
+----
+
+[[exempt-branch-example]]
+=== Exempt a branch Example
+
+We could exempt a submit requirement from certain branches. For example,
+project owners might want to skip the 'Code-Style' requirement from the
+refs/meta/config branch.
+
+----
+[submit-requirement "Code-Style"]
+  description = Code is properly styled and formatted
+  applicableIf = -branch:refs/meta/config
+  submittableIf = label:Code-Style=+1 AND -label:Code-Style=-1
+  canOverrideInChildProjects = true
+----
+
+Branch configuration supports regular expressions as well, e.g. to exempt 'refs/heads/release/*' pattern,
+when migrating from the label Submit-Rule:
+
+----
+[label "Verified"]
+  branch = refs/heads/release/*
+----
+
+The following SR can be configured:
+
+----
+[submit-requirement "Verified"]
+  submittableIf = label:Verified=MAX AND -label:Verified=MIN
+  applicableIf = branch:^refs/heads/release/.*
+----
+
+
+[[test-submit-requirements]]
+== Testing Submit Requirements
+
+The link:rest-api-changes.html#check-submit-requirement[Check Submit Requirement]
+change endpoint can be used to test submit requirements on any change. Users
+are encouraged to test submit requirements before adding them to the project
+to ensure they work as intended.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+    {
+      "name": "Code-Review",
+      "submittability_expression": "label:Code-Review=+2"
+    }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "status": "SATISFIED",
+    "submittability_expression_result": {
+      "expression": "label:Code-Review=+2",
+      "fulfilled": true,
+      "passingAtoms": [
+        "label:Code-Review=+2"
+      ]
+    },
+    "is_legacy": false
+  }
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index a83c747..73cfc55 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -4,11 +4,12 @@
 the browser, allowing organizations to alter the look and
 feel of the application to fit with their general scheme.
 
-== HTML Header/Footer and CSS
+== HTML Header/Footer and CSS for login screens
 
-The HTML header, footer and CSS may be customized for login
-screens (LDAP, OAuth, OpenId) and the internally managed
-Gitweb servlet.
+The HTML header, footer, and CSS may be customized for login screens (LDAP,
+OAuth, OpenId) and the internally managed Gitweb servlet. See
+link:pg-plugin-dev.html[JavaScript Plugin Development and API] for documentation
+on modifying styles for the rest of Gerrit (not login screens).
 
 At startup Gerrit reads the following files (if they exist) and
 uses them to customize the HTML page it sends to clients:
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index c3237ed..d1a5bcf 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -18,7 +18,7 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|11|...
+* A JDK for Java 11 or Java 17
 * Python 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
 * Bower (`npm install -g bower`)
@@ -48,73 +48,30 @@
 
 `java -version`
 
-[[java-8]]
-==== Java 8 support (deprecated)
-
-Java 8 is a legacy Java release and support for Java 8 will be discontinued
-in future gerrit releases. To build Gerrit with Java 8 language level, run:
-
-```
-  $ bazel build --java_toolchain //tools:error_prone_warnings_toolchain :release
-```
-
 [[java-11]]
 ==== Java 11 support
 
-Java language level 11 is the default. To build Gerrit with Java 11 language
-level, run:
+To build Gerrit with Java 11 language level, run:
 
 ```
-  $ bazel build :release
+  $ bazel build --java_toolchain=//tools:error_prone_warnings_toolchain_java11 :release
 ```
 
-[[java-13]]
-==== Java 13 support
+[[java-17]]
+==== Java 17 support
 
-Java 13 (and newer) is supported through vanilla java toolchain
-link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option,role=external,window=_blank].
-To build Gerrit with Java 13 and newer, specify vanilla java toolchain and
-provide the path to JDK home:
+Java 17 is supported. To build Gerrit with Java 17, run:
 
 ```
-  $ bazel build \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-13> \
-    --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 \
-    :release
+  $ bazel build --config=java17 :release
 ```
 
-To run the tests, `--javabase` option must be passed as well, because
-bazel test runs the test using the target javabase:
+To run the tests with Java 17, run:
 
 ```
-  $ bazel test \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-13> \
-    --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 \
-    //...
+  $ bazel test --config=java17 //...
 ```
 
-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-13>
-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.
-
 === 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,role=external,window=_blank].
 
@@ -268,7 +225,15 @@
   bazel-bin/Documentation/searchfree.zip
 ----
 
-To build the executable WAR with the documentation included:
+To generate HTML files skipping the zip archiving:
+
+----
+  bazel build Documentation
+----
+
+And open `bazel-bin/Documentation/index.html`.
+
+To build the Gerrit executable WAR with the documentation included:
 
 ----
   bazel build withdocs
@@ -317,18 +282,6 @@
   bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
-To run SSH tests using JSch ssh client:
-
-----
-  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=JSCH //...
-----
-
-To run SSH tests using Apache MINA ssh client:
-
-----
-  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=APACHE //...
-----
-
 To run only tests that do not use SSH:
 
 ----
@@ -341,12 +294,6 @@
   bazel test --test_tag_filters=-flaky //...
 ----
 
-To exclude tests that require a Docker host:
-
-----
-  bazel test --test_tag_filters=-docker //...
-----
-
 To exclude tests that require very recent git client version:
 
 ----
@@ -370,15 +317,11 @@
   bazel test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
 ----
 
-Elastic search is not currently supported in integration tests.
-
 The following values are currently supported for the group name:
 
 * annotation
 * api
-* docker
 * edit
-* elastic
 * git
 * git-protocol-v2
 * git-upload-archive
@@ -411,23 +354,6 @@
 Now attach with a debugger to the port `5005`. For example use "Remote Java Application" launch
 configuration in Eclipe and specify the port `5005`.
 
-[[elasticsearch]]
-=== Elasticsearch
-
-Successfully running the Elasticsearch tests requires Docker, and
-may require setting the local virtual memory on
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[linux,role=external,window=_blank] and
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#_set_vm_max_map_count_to_at_least_262144[macOS,role=external,window=_blank].
-
-On macOS, if using link:https://docs.docker.com/docker-for-mac/[Docker Desktop,role=external,window=_blank],
-the effective memory value can be set in the Preferences, under the Advanced tab.
-The default value usually does not suffice and is causing premature container exits.
-That default is currently 2 GB and should be set to at least 5 (GB).
-
-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,role=external,window=_blank].
-
 [[logging]]
 === Controlling logging level
 
@@ -681,6 +607,40 @@
     --disk-size=200
 ```
 
+Due to outdated Git version in official RBE docker images, a custom RBE docker
+image must be used. To build custom docker imager, change to the directory
+`tools/platforms` and build and publish custom RBE docker image.
+
+To build the custom RBE docker image, run:
+
+```
+docker build -t gcr.io/api-project-164060093628/rbe-ubuntu18-04 .
+```
+
+To publish the custom RBE docker image, run:
+
+```
+docker push gcr.io/api-project-164060093628/rbe-ubuntu18-04
+[...]
+latest: digest: sha256:de5186d4313630a6111f9a2449b72563d0bc59ec9fb60956f063b69a38a76834 size: 1584
+```
+
+Re-build rbe_autoconfig project conduct a new release and switch to using it
+in `WORKSPACE` file.
+
+Note, to authenticate to the gcr.io registry, the following command must be
+used:
+
+```
+gcloud auth configure-docker
+```
+
+To see the documentation, developer must be added to this group:
+https://groups.google.com/forum/#!forum/rbe-alpha-customers.
+
+Documentation can be found at:
+https://cloud.google.com/remote-build-execution/docs.
+
 To use RBE, execute
 
 ```
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 7488f74..47c0be2 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -54,7 +54,6 @@
 * link:dev-build-plugins.html[Building Gerrit plugins]
 * link:pg-plugin-dev.html[JavaScript Plugin Development and API]
 * link:config-validation.html[Validation Interfaces]
-* link:dev-stars.html[Starring Changes]
 * link:quota.html[Quota Enforcement]
 
 [[maintainer]]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index fcc8b7e..107473a 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -24,33 +24,31 @@
 link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
 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,role=external,window=_blank] before starting with the implementation.
+For large/complex features, it is required to specify the feature in a
+link:dev-design-docs.html[design document,role=external,window=_blank] before
+starting implementation, as per the
+link:#design-driven-contribution-process[design-driven contribution process].
 
 If link:dev-roles.html#contributor[contributors,role=external,window=_blank]
-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,role=external,window=_blank] can
-require to follow the design-driven contribution process instead.
+choose the lightweight contribution process but the feature is found to be 
+large or complex, link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
+can require that the design-driven contribution process be followed 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,role=external,window=_blank]
 mailing list.
 
 These contribution processes apply to everyone who contributes code to
-the Gerrit project, including link:dev-roles.html#maintainer[
-maintainers,role=external,window=_blank]. When reading this document, keep in
-mind that maintainers are also contributors when they contribute code.
+the Gerrit project. link:dev-roles.html#maintainer[
+Maintainers,role=external,window=_blank] are also considered 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.
+maintainer who can take the time that is needed for a thorough review. This
+can result in unpredictably long waiting times before the changes are
+submitted. To avoid that, contributors can ask for link:#mentorship[mentor support].
+A mentor helps with timely code reviews and technical guidance, though the 
+implementation itself is still the responsibility of the contributor.
 
 [[comparison]]
 === Quick Comparison
@@ -66,8 +64,8 @@
 |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
+done, maintainers may require the design-driven contribution process
+be followed 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|
@@ -83,40 +81,32 @@
 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 link:https://gerrit-review.googlesource.com/[
-gerrit-review.googlesource.com,role=external,window=_blank] Gerrit server.  To
-help speed up the review of your change, review these link:dev-crafting-changes.html[
+To start contributing to Gerrit, upload your git commit for review to the
+link:https://gerrit-review.googlesource.com/[gerrit-review.googlesource.com,
+role=external,window=_blank] Gerrit server. Review these link:dev-crafting-changes.html[
 guidelines,role=external,window=_blank] 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,role=external,window=_blank].
+view pending contributions link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
 
 Depending on the size of that list it might take a while for
-your change to get reviewed.  Naturally there are fewer
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank], that
-can approve changes, than link:dev-roles.html#contributor[contributors,role=external,window=_blank];
-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,
+your change to get reviewed. 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
+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.
+Finally, the quicker you respond to the comments of your reviewers, the
+quicker your change can be 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.
 
-Features or API extensions, even if they are small, will incur
-long-time maintenance and support burden, so they should be left
-pending for at least 24 hours to give maintainers in all timezones a
-chance to evaluate.
+A new feature or API extension, even if small, can incur a long-time
+maintenance and support burden and should be left pending for 24 hours
+to give maintainers in all time zones a chance to evaluate the change.
 
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
@@ -126,19 +116,19 @@
 
 For large/complex features it is important to:
 
-* agree on the functionality and scope before spending too much time
-  on the implementation
+* agree on functionality and scope before spending too much time on
+  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
+* consider how the feature could be evolved over time
 
 This is why for large/complex features it is required to describe the
 feature in a link:dev-design-docs.html[design doc,role=external,window=_blank]
 and get it approved by the
-link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
+link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
 before starting the implementation.
 
-The design-driven contribution process has the following steps:
+The design-driven contribution process consists of the following steps:
 
 * A link:dev-roles.html#contributor[contributor,role=external,window=_blank]
   link:dev-design-docs.html#propose[proposes,role=external,window=_blank] a new
@@ -155,40 +145,31 @@
   be accepted.
 * To be submitted, the design doc needs to be approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank].
-* After the design was approved, the implementation is done by pushing
-  changes for review, see link:#lightweight-contribution-process[
+* After the design is approved, it is implemented by pushing
+  changes for review, see the 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.
+  main driver of the implementation and responsible for its completion.
+  Others from the Gerrit community are usually welcome to help.
 
-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.
+The design doc does not need to fully specify each detail of the feature,
+but its concept and how it fits into Gerrit should be sufficiently clear,
+as 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.
 
-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. Another way to demonstrate the
-design can be to add screenshots or the like, early enough in the doc.
+While the design doc is still in review, contributors may 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 is not yet approved. Another way to demonstrate the design can be
+mocking screenshots in the doc.
 
 By approving a design, the steering committee commits to:
 
 * Accepting the feature when it is implemented.
 * Supporting the feature by assigning a link:dev-roles.html#mentor[
-  mentor,role=external,window=_blank] (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.
+  mentor,role=external,window=_blank] if requested (see link:#mentorship[mentorship]).
 
 For contributors, the design-driven contribution process has the
 following advantages:
@@ -196,12 +177,11 @@
 * 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
+* Once a design is approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
   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.
+  Hence, there little risk of the feature being rejected later in code review,
+  as can occur 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.
@@ -249,12 +229,11 @@
 * done criteria that define when the feature is done and the mentorship
   ends
 
-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.
+If a feature implementation is not completed in time and no contributors
+can commit to finishing the implementation, changes that have already been
+submitted for the feature may be reverted to avoid unused or half-finished
+code in the code base. In these circumstances, the steering committee
+determines how to proceed.
 
 [[esc-dd-evaluation]]
 == How the ESC evaluates design documents
@@ -314,7 +293,7 @@
 === Core vs. Plugin decision
 Q: `Would this fit better in a plugin?`
 
-* Yes:The proposed feature or rework is an implementation (e.g. Lucene
+* Yes: The proposed feature or rework is an implementation (e.g. Lucene
   is an index implementation) of a generic concept that others
   might want to implement differently.
 * Yes: The proposed feature or rework is very specific to a custom setup.
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index 6dc6f5f..efba520 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -13,7 +13,7 @@
 * Use-Cases:
   The interactions between a user and a system to attain particular
   goals.
-* Acceptance Criteria
+* Acceptance Criteria:
   Conditions that must be satisfied to consider the feature as done.
 * Background:
   Stuff one needs to know to understand the use-cases (e.g. motivating
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index c50a293..1151f1c 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -102,8 +102,7 @@
 === SSH keys
 
 If you are running SSH commands, the private keys of the users used for testing need to go in
-`/tmp/ssh-keys`. The keys need to be generated this way (JSch won't validate them
-link:https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch[otherwise,role=external,window=_blank]):
+`/tmp/ssh-keys`. The keys need to be generated this way and won't be validated.
 
 ----
 mkdir /tmp/ssh-keys
@@ -174,6 +173,9 @@
 * `-Dcom.google.gerrit.scenarios.ssh_port=29418`
 * `-Dcom.google.gerrit.scenarios.http_port=8080`
 * `-Dcom.google.gerrit.scenarios.http_scheme=http`
+* `-Dcom.google.gerrit.scenarios.username=admin`
+* `-Dcom.google.gerrit.scenarios.replica_hostname=localhost`
+* `-Dcom.google.gerrit.scenarios.project_prefix=`
 
 Above, the properties can be set with values matching specific deployment topologies under test.
 The name of the property corresponds to the uppercase keyword found in the json file. For example,
@@ -195,6 +197,20 @@
 That whole replication time depends on the system under test. Therefore, this property here should
 be set to a value high enough, so that the test checks for a done replication at the right time.
 
+==== Context path
+
+The `context_path` property allows test scenarios to send Gerrit REST requests to Gerrit instances
+that use a context path in the URL. Its default is no context path and can be set using another value:
+
+* `-Dcom.google.gerrit.scenarios.context_path=/context`
+
+==== Authentication
+
+The `authenticated` property allows test scenarios to use authenticated HTTP clones. Its default is
+no authentication:
+
+* `-Dcom.google.gerrit.scenarios.authenticated=false`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index e18d7b0..79febe4 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -31,9 +31,6 @@
 ----
 
 First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
-If running Eclipse on Java 8, add the extra parameter
-`-e='--java_toolchain=//tools:error_prone_warnings_toolchain'`
-for generating a compatible project.
 
 Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
@@ -118,11 +115,11 @@
 
 == Testing
 
-=== The Gerrit web app UI is served by `server.go` process. To launch it,
+=== The Gerrit web app UI is served by `Web Dev Server`. To launch it,
 run this command:
 
 ----
-  $ bazel run polygerrit-ui:devserver
+  $ npm run start
 ----
 
 === Running the Daemon
diff --git a/Documentation/dev-plugins-lifecycle.txt b/Documentation/dev-plugins-lifecycle.txt
index d5bd791..2f5ffb7 100644
--- a/Documentation/dev-plugins-lifecycle.txt
+++ b/Documentation/dev-plugins-lifecycle.txt
@@ -15,14 +15,14 @@
 The idea of creating a new plugin is posted and discussed on the
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] mailing list.
 +
-Also see section link#ideation_discussion[Ideation and discussion] below.
+Also see section <<ideation_discussion>> below.
 
 - Prototyping (optional):
 +
 The author of the plugin creates a working prototype on a public repository
 accessible to the community.
 +
-Also see section link#plugin_prototyping[Plugin Prototyping] below.
+Also see section <<plugin_prototyping>> below.
 
 - Proposal and Hosting:
 +
@@ -37,7 +37,7 @@
 plugins path on link:https://gerrit-review.googlesource.com[the Gerrit project
 site,role=external,window=_blank].
 +
-Also see section link#plugin_proposal[Plugin Proposal] below.
+Also see section <<plugin_proposal>> below.
 
 - Build:
 +
@@ -46,14 +46,14 @@
 link:https://gerrit-ci.gerritforge.com[the GerritForge CI,role=external,window=_blank] that build the
 plugin for each Gerrit version that it supports.
 +
-Also see section link#build[Build] below.
+Also see section <<build>> below.
 
 - Development and Contribution:
 +
 The author develops a production-ready code base of the plugin, with
 contributions, reviews, and help from the Gerrit community.
 +
-Also see section link#development_contribution[Development and contribution]
+Also see section <<development_contribution>>
 below.
 
 - Release:
@@ -62,7 +62,7 @@
 on the link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 +
-Also see section link#plugin_release[Plugin release] below.
+Also see section <<plugin_release>> below.
 
 - Maintenance:
 +
@@ -75,7 +75,7 @@
 The author declares that the plugin is not maintained anymore or is deprecated
 and should not be used anymore.
 +
-Also see section link#plugin_deprecation[Plugin deprecation] below.
+Also see section <<plugin_deprecation>> below.
 
 [[ideation_discussion]]
 == Ideation and Discussion
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index fa2b78c..33c5bbd 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2,8 +2,9 @@
 = 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 and hosted
-on gerrit-review.googlesource.com.
+This page describes how plugins for Gerrit can be developed. See the overall
+link:dev-plugins-lifecycle.html[Plugin Lifecycle] document for how plugins can
+be hosted on gerrit-review.googlesource.com.
 
 For JavaScript plugin development, consult with
 link:pg-plugin-dev.html[JavaScript Plugin Development] guide.
@@ -207,6 +208,28 @@
 The canonical web URL may be injected into any .jar plugin regardless of
 whether or not the plugin provides an HTTP servlet.
 
+[[plugin_resources]]
+=== Plugin resources
+
+Plugins are able to access their own resources without having to go through
+the implementation details on how they are packaged or deployed to Gerrit.
+
+The following example shows a MyClass in a plugin that is able to access the
+last modified time of the "myresource" loaded.
+
+[source,java]
+----
+public class MyClass {
+
+  @Inject
+  public MyClass(Plugin plugin) {
+    long myresourceTime = plugin.getContentScanner().getEntry("myresource").getTime();
+  }
+
+  [...]
+}
+----
+
 [[reload_method]]
 === Reload Method
 
@@ -415,6 +438,17 @@
 +
 Publication of usage data
 
+* `com.google.gerrit.extensions.events.GitReferenceUpdatedListener`:
++
+A git reference was updated. A separate event for every ref updated in
+a BatchRefUpdate will be fired.
+
+* `com.google.gerrit.extensions.events.GitBatchRefUpdateListener`:
++
+One or more git references were updated. Alternative to GitReferenceUpdatedListener.
+A single event will inform about all refs updated by a BatchRefUpdate. Will also be
+fired, if only a single ref was updated.
+
 * `com.google.gerrit.extensions.events.GarbageCollectorListener`:
 +
 Garbage collection ran on a project
@@ -499,6 +533,24 @@
 Certain operations in Gerrit can be validated by plugins by
 implementing the corresponding link:config-validation.html[listeners].
 
+[[taskListeners]]
+== WorkQueue.TaskListeners
+
+It is possible for plugins to listen to
+`com.google.gerrit.server.git.WorkQueue$Task`s directly before they run, and
+directly after they complete. This may be used to delay task executions based
+on custom criteria by blocking, likely on a lock or semaphore, inside
+onStart(), and a lock/semaphore release in onStop(). Plugins may listen to
+tasks by implementing a `com.google.gerrit.server.git.WorkQueue$TaskListener`
+and registering the new listener like this:
+
+[source,java]
+----
+bind(TaskListener.class)
+    .annotatedWith(Exports.named("MyListener"))
+    .to(MyListener.class);
+----
+
 [[change-message-modifier]]
 == Change Message Modifier
 
@@ -838,6 +890,63 @@
 }
 ----
 
+Plugins can receive a bean object for each of the gerrit ssh and the REST API
+commands by implementing BeanParseListener interface and registering it to a
+command class name in the plugin module's `configure()` method. The below
+example shows a plugin that always limits the number of projects returned
+by the ls-projects SSH command.
+
+[source, java]
+----
+protected static class PluginModule extends AbstractModule {
+  @Override
+  public void configure() {
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(ListProjectsCommand.class))
+        .to(ListProjectsCommandBeanListener.class);
+  }
+
+  protected static class ListProjectsCommandBeanListener
+      implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ListProjectsCommand command = (ListProjectsCommand) bean;
+      command.impl.setLimit(1);
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+}
+----
+
+The below example shows a plugin that always limits the number of projects
+returned by the /projects/ REST API.
+
+[source, java]
+----
+protected static class PluginModule extends AbstractModule {
+  @Override
+  public void configure() {
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(ListProjects.class))
+        .to(ListProjectsBeanListener.class);
+  }
+
+  protected static class ListProjectsBeanListener
+      implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ListProjects listProjects = (ListProjects) bean;
+      listProjects.setLimit(1);
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+}
+----
+
 === Calling Command Options ===
 
 Within an OptionHandler, during the processing of an option, plugins can
@@ -899,7 +1008,7 @@
 [[query_attributes]]
 == Change Attributes
 
-==== ChangePluginDefinedInfoFactory
+=== ChangePluginDefinedInfoFactory
 
 Plugins can provide additional attributes to be returned from the Get Change and
 Query Change APIs by implementing the `ChangePluginDefinedInfoFactory` interface
@@ -2131,6 +2240,8 @@
 DiffWebLinks will appear in the side-by-side and unified diff screen in
 the header next to the navigation icons.
 
+EditWebLinks will appear in the top-right part of the file diff page.
+
 ProjectWebLinks will appear in the project list in the
 `Repository Browser` column.
 
@@ -2209,8 +2320,7 @@
 DropWizard Metrics,role=external,window=_blank].
 
 Metric reporting plugin implementations are provided for
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX,role=external,window=_blank],
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-elasticsearch/[Elastic Search,role=external,window=_blank],
+link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX,role=external,window=_blank]
 and link:https://gerrit.googlesource.com/plugins/metrics-reporter-graphite/[Graphite,role=external,window=_blank].
 
 There is also a working example of reporting metrics to the console in the
@@ -2628,7 +2738,7 @@
 Plugins may also decide not to vote on a given change by returning an
 `Optional.empty()` (ie: the plugin is not enabled for this repository).
 
-If a plugin decides not to vote, it's name will not be displayed in the UI and
+If a plugin decides not to vote, its name will not be displayed in the UI and
 it will not be recoded in the database.
 
 .Gerrit's Pre-submit handling with three plugins
@@ -2712,6 +2822,23 @@
 prevent callers using ETags from potentially seeing outdated submittability
 information.
 
+`SubmitRule` interface will soon deprecated. Instead, a global `SubmitRequirement`
+can be bound by plugin.
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.inject.AbstractModule;
+
+public class MyPluginModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(SubmitRequirement.class).annotatedWith(Exports.named("myPlugin"))
+        .toInstance(myPluginSubmitRequirement);
+  }
+}
+----
+
 [[change-etag-computation]]
 == Change ETag Computation
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index e43e021..3bd88f5 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -366,7 +366,10 @@
 * 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.
+Only maintainers from Google can vote on the `Library-Compliance` label. The
+Gerrit team at Google uses this
+link:https://gerrit-review.googlesource.com/q/label:%2522Library-Compliance%253Dneed%2522+-ownerin:google-gerrit-team+status:open+project:gerrit+-age:4week+-is:wip+-is:private+label:Code-Review%252B2[change query]
+to find changes that require a `Library-Compliance` approval.
 
 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
@@ -378,6 +381,12 @@
 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.
 
+[[escalation-channel-to-google]]
+== Escalation channel to Google
+
+If anything urgent is blocking that requires the attention of a Googler you may
+escalate this by writing an email to Han-Wen Nienhuys: hanwen@google.com
+
 [[deprecating-features]]
 == Deprecating features
 
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index f045ab8..6ff064c 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,6 +178,9 @@
 NOTE: To learn why using `java -jar` isn't sufficient, see
 <<special_bazel_java_version,this explanation>>.
 
+NOTE: When launching the daemong this way, the settings from the `[container]` section from the
+`$GERRIT_SITE/etc/gerrit.config` are not honored.
+
 To debug the Gerrit server of this test site:
 
 .  Open a debug port (such as port 5005). To do so, insert the following code
@@ -188,6 +191,49 @@
 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
 ----
 
+=== Running the Daemon honoring the [container] section settings
+
+To run the Daemon and honor the `[container]` section settings use the `gerrit.sh` script:
+
+----
+  $ cd $GERRIT_SITE
+  $ bin/gerrit.sh run
+----
+
+To run the Daemon in debug mode use the `--debug` option:
+
+----
+  $ bin/gerrit.sh run --debug
+----
+
+The default debug port is `8000`. To specify a different debug port use the `--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-port=5005
+----
+
+The `--debug-address` option also exists and is a synonym for the ``--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=5005
+----
+
+Note that, by default, the debugger will only accept connections from the localhost. To enable
+debug connections from other host(s) provide also a host name or wildcard in the `--debug-address`
+value:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=*:5005
+----
+
+Debugging the Daemon startup requires starting the JVM in suspended debug mode. The JVM will await
+for a debugger to attach before proceeding with the start. Use the `--suspend` option for that
+scenario:
+
+----
+  $ bin/gerrit.sh run --debug --suspend
+----
+
 === Running the Daemon with Gerrit Inspector
 
 link:dev-inspector.html[Gerrit Inspector] is an interactive scriptable
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index a4ccccf..db08da5 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -44,15 +44,13 @@
 +
 Generate and publish a PGP key as described in
 link:http://central.sonatype.org/pages/working-with-pgp-signatures.html[
-Working with PGP Signatures,role=external,window=_blank]. In addition to the keyserver mentioned
-there it is recommended to also publish the key to the
-link:https://keyserver.ubuntu.com/[Ubuntu key server].
+Working with PGP Signatures,role=external,window=_blank].
 +
 Please be aware that after publishing your public key it may take a
 while until it is visible to the Sonatype server.
 +
 Add an entry for the public key in the
-link:https://gerrit.googlesource.com/homepage/+/md-pages/releases/public-keys.md[key list,role=external,window=_blank]
+link:https://gerrit.googlesource.com/homepage/+/master/pages/site/releases/public-keys.md[key list,role=external,window=_blank]
 on the homepage.
 +
 The PGP passphrase can be put in `~/.m2/settings.xml`:
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 0849c56..40470a6 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -327,6 +327,20 @@
   git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"'
 ----
 
+[[publish-typescript-plugin-api]]
+==== Publish TypeScript Plugin API
+
+Only applies to major and minor releases! Not required for patch releases.
+
+* Publish a new version of the npm package `@gerritcodereview/typescript-api`.
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/README.md[api/README.md,role=external,window=_blank]
+for details.
+
+* The plugins in the stable branch of the minor release and the master branch
+be changed to use the new API version, see
+link:https://gerrit-review.googlesource.com/c/gerrit/+/340069[
+example change,role=external,window=_blank]
+
 [[upload-documentation]]
 ==== Upload the Documentation
 
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index cecaedc..d8f7e11 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -66,11 +66,12 @@
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list):
 
-* become member of the `gerrit-verifiers` group, which allows to:
+* become member of the `gerrit-trusted-contributors` 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
+** revert changes
 * approve posts to the
   link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list
@@ -106,14 +107,14 @@
 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`
+Regular contributors can ask to be added to the `gerrit-trusted-contributors`
 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
+Being member of the `gerrit-trusted-contributors` group includes further
 permissions (see link:#supporter[supporter] section above).
 
 It's highly appreciated if contributors engage in code reviews,
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
deleted file mode 100644
index 764e326..0000000
--- a/Documentation/dev-stars.txt
+++ /dev/null
@@ -1,82 +0,0 @@
-= Gerrit Code Review - Stars
-
-== Description
-
-Changes can be starred with labels that behave like private hashtags.
-Any label can be applied to a change, but these labels are only visible
-to the user for which the labels have been set.
-
-Stars allow users to categorize changes by self-defined criteria and
-then build link:user-dashboards.html[dashboards] for them by making use
-of the link:#query-stars[star query operators].
-
-[[star-api]]
-== Star API
-
-The link:rest-api-accounts.html#star-endpoints[star REST API] supports:
-
-* link:rest-api-accounts.html#get-stars[
-  get star labels from a change]
-* link:rest-api-accounts.html#set-stars[
-  update star labels on a change]
-* link:rest-api-accounts.html#get-starred-changes[
-  list changes that are starred by any label]
-
-Star labels are also included in
-link:rest-api-changes.html#change-info[ChangeInfo] entities that are
-returned by the link:rest-api-changes.html[changes REST API].
-
-There are link:rest-api-accounts.html#default-star-endpoints[
-additional REST endpoints] for the link:#default-star[default star].
-
-[[default-star]]
-== Default Star
-
-If the default star is set by a user, this user is automatically
-notified by email whenever updates are made to that change.
-
-The default star is the star that is shown in the WebUI and which can
-be updated from there.
-
-The default star is represented by the special star label 'star'.
-
-[[ignore-star]]
-== Ignore Star
-
-If the ignore star is set by a user, this user gets no email
-notifications for updates of that change, even if this user is a
-reviewer of the change or the change is matched by a project watch of
-the user.
-
-Since changes can only be ignored once they are created, users that
-watch a project will always get the email notifications for the change
-creation. Only then the change can be ignored.
-
-Users that are added as reviewer or assignee to a change that they have
-ignored will be notified about this, so that they know about the review
-request. They can then decide to remove the ignore star.
-
-The ignore star is represented by the special star label 'ignore'.
-
-[[query-stars]]
-== Query Stars
-
-There are several query operators to find changes with stars:
-
-* link:user-search.html#is-starred[is:starred] /
-  link:user-search.html#has-star[has:star]:
-  Matches any change that was starred by the current user with the
-  link:#default-star[default star].
-
-[[syntax]]
-== Syntax
-
-Star labels cannot contain whitespace characters. All other characters
-are allowed.
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/externalid-case-insensitivity.txt b/Documentation/externalid-case-insensitivity.txt
new file mode 100644
index 0000000..b4e8140
--- /dev/null
+++ b/Documentation/externalid-case-insensitivity.txt
@@ -0,0 +1,128 @@
+:linkattrs:
+= Gerrit Code Review - ExternalId case insensitivity
+
+Gerrit usernames are case insensitive by default: e.g. johndoe and JohnDoe
+represents the same account. However, for installations older than v3.5.x,
+the usernames were case sensitive, e.g. johndoe and JohnDoe can both exist
+as separate accounts. This could lead to issues when migrating an account
+from LDAP to an internal account, if ldap.localUsernameToLowerCase was set.
+Such usernames can also be rather confusing for users, if they try to identify
+authors of comments or changes.
+
+When Gerrit handles case insensitive usernames (external IDs using the
+`gerrit:` or `username:` scheme, their external IDs SHA-1 is always computed
+using the lowercase external ID, hence there cannot be any account differing
+only in the capitalization of their usernames.
+
+Gerrit installations older than v3.5.x that are switching to the case-insensitive
+username need to migrating all their existing accounts SHA-1s.
+
+[[migration]]
+== Migration
+
+Migrating external ID notes can take several minutes for large sites(for example
+migration ~45000 accounts can take up to five minutes), so administrators choose
+whether to do the migration offline or online, depending on their available
+resources and tolerance for downtime.
+
+NOTE: Migration is required only on Gerrit primary instances.
+
+[[offline-migration]]
+=== Offline
+
+To run the offline migration execute following steps:
+* Stop all Gerrit primary instances
+* Set the `auth.userNameCaseInsensitive` to false
+----
+[auth]
+  userNameCaseInsensitive = false
+----
+
+* Run:
+[verse]
+--
+_java_ -jar gerrit.war _ChangeExternalIdCaseSensitivity_
+  -d <SITE_PATH>
+  [--batch]
+--
+
+See: link:pgm-ChangeExternalIdCaseSensitivity.html
+
+* During the migration `auth.userNameCaseInsensitive` will be set to true
+on a node which is executing the migration. When the migration is finished,
+on all other primary nodes set `auth.userNameCaseInsensitive` to true
+* Start all Gerrit primary instances
+
+[[online-migration]]
+=== Online
+
+To start the online migration, set the `auth.userNameCaseInsensitive` and
+`auth.userNameCaseInsensitiveMigrationMode` options in `gerrit.config` and
+restart Gerrit:
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = true
+----
+* Trigger online migration:
+----
+$ ssh -p <port> <host> gerrit migrate-externalids-to-insensitive
+----
+
+See: link:cmd-migrate-externalids-to-insensitive.html
+
+[online-ha-migration]
+== Online migration for high-availability setup
+
+To start the online migration with a setup containing multiple primary
+instances execute following steps:
+* On all Gerrit primary instances set `auth.userNameCaseInsensitive` and
+`auth.userNameCaseInsensitiveMigrationMode` and perform a rolling restart
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = true
+----
+* Trigger online migration:
+----
+$ ssh -p <port> <host> gerrit migrate-externalids-to-insensitive
+----
+
+See: link:cmd-migrate-externalids-to-insensitive.html
+
+* When the migration is finished, on all other primary nodes set
+`auth.userNameCaseInsensitiveMigrationMode` to false and perform a
+rolling restart
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = false
+----
+
+== External ID case insensitivity rollback
+
+The offline migration tool allows to calculate external ID notes named with the SHA-1
+from the case sensitive external ID.
+
+To rollback external ID notes migration execute following steps:
+* Stop all Gerrit primary instances
+* Set the `auth.userNameCaseInsensitive` to true
+----
+[auth]
+  userNameCaseInsensitive = true
+----
+
+* Run:
+[verse]
+--
+_java_ -jar gerrit.war _ChangeExternalIdCaseSensitivity_
+  -d <SITE_PATH>
+  [--batch]
+--
+
+See: link:pgm-ChangeExternalIdCaseSensitivity.html
+
+* During the migration `auth.userNameCaseInsensitive` will be set to false
+on a node which is executing the migration. When the migration is finished,
+on all other primary nodes set `auth.userNameCaseInsensitive` to false
+* Start all Gerrit primary instances
diff --git a/Documentation/images/browser-notification-example.png b/Documentation/images/browser-notification-example.png
new file mode 100644
index 0000000..2b60054
--- /dev/null
+++ b/Documentation/images/browser-notification-example.png
Binary files differ
diff --git a/Documentation/images/browser-notification-preference.png b/Documentation/images/browser-notification-preference.png
new file mode 100644
index 0000000..57d5fd6
--- /dev/null
+++ b/Documentation/images/browser-notification-preference.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-apply-fix.png b/Documentation/images/user-review-ui-apply-fix.png
new file mode 100644
index 0000000..d838d48
--- /dev/null
+++ b/Documentation/images/user-review-ui-apply-fix.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-metadata.png b/Documentation/images/user-review-ui-change-metadata.png
new file mode 100644
index 0000000..23abc07
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-metadata.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-annotated.png b/Documentation/images/user-review-ui-change-screen-annotated.png
index 5c3f80a..4e12c96 100644
--- a/Documentation/images/user-review-ui-change-screen-annotated.png
+++ b/Documentation/images/user-review-ui-change-screen-annotated.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-labels.png b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
deleted file mode 100644
index 61e2b25..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-labels.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-comments-tab.png b/Documentation/images/user-review-ui-change-screen-comments-tab.png
new file mode 100644
index 0000000..d522f60
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-comments-tab.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list.png b/Documentation/images/user-review-ui-change-screen-file-list.png
index 721b229..b0c2af3 100644
--- a/Documentation/images/user-review-ui-change-screen-file-list.png
+++ b/Documentation/images/user-review-ui-change-screen-file-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
index 9ef8f27..224de2d 100644
--- a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
+++ b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/user-review-ui-change-screen-reply.png
index 1c50fc5..201db13 100644
--- a/Documentation/images/user-review-ui-change-screen-reply.png
+++ b/Documentation/images/user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-topleft.png b/Documentation/images/user-review-ui-change-screen-topleft.png
index a1f7813..b3bf8e7f 100644
--- a/Documentation/images/user-review-ui-change-screen-topleft.png
+++ b/Documentation/images/user-review-ui-change-screen-topleft.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen.png b/Documentation/images/user-review-ui-change-screen.png
index ff2570b..98a5d6d 100644
--- a/Documentation/images/user-review-ui-change-screen.png
+++ b/Documentation/images/user-review-ui-change-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-copy-links.png b/Documentation/images/user-review-ui-copy-links.png
new file mode 100644
index 0000000..f8fa114
--- /dev/null
+++ b/Documentation/images/user-review-ui-copy-links.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
index 047034c..98cf7af 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
index 74d02e3..ebdd177 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-submit-requirements.png b/Documentation/images/user-review-ui-submit-requirements.png
new file mode 100644
index 0000000..e4b88c1
--- /dev/null
+++ b/Documentation/images/user-review-ui-submit-requirements.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-suggest-fix.png b/Documentation/images/user-review-ui-suggest-fix.png
new file mode 100644
index 0000000..e08fb26
--- /dev/null
+++ b/Documentation/images/user-review-ui-suggest-fix.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 782a6a9..89b88aa 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -15,6 +15,7 @@
 == Contributor Guides
 . link:dev-community.html[Gerrit Community]
 . link:dev-community.html#how-to-contribute[How to Contribute]
+.. link:dev-readme.html[Developer Setup]
 
 == User Guides
 . link:intro-user.html[User Guide]
@@ -23,7 +24,7 @@
 
 == Tutorials
 . Web
-.. link:user-review-ui.html[Reviewing Changes]
+.. link:user-review-ui.html[Review UI Overview]
 .. link:user-search.html[Searching Changes]
 .. link:user-inline-edit.html[Manipulating Changes in Browser]
 .. link:user-notify.html[Subscribing to Email Notifications]
@@ -43,6 +44,7 @@
 == Project Management
 . link:project-configuration.html[Project Configuration]
 .. link:config-labels.html[Review Labels]
+.. link:config-submit-requirements.html[Submit Requirements]
 .. link:config-project-config.html[Project Configuration File Format]
 . link:access-control.html[Access Controls]
 . Multi-project management
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 6565ba4..345eb1c 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -130,7 +130,7 @@
 In general, the *Code-Review* check requires an individual to look at the code,
 while the *Verified* check is done by an automated build server, through a
 mechanism such as the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger
 Jenkins Plugin,role=external,window=_blank].
 
 IMPORTANT: The Code-Review and Verified checks require different permissions
@@ -161,8 +161,8 @@
 * `+2 Looks good to me, approved`
 * `+1 Looks good to me, but someone else must approve`
 * `0 No score`
-* `-1 I would prefer that you didn't submit this`
-* `-2 Do not submit`
+* `-1 I would prefer this is not submitted as is`
+* `-2 This shall not be submitted`
 
 In addition, a change must have at least one `+2` vote and no `-2` votes before
 it can be submitted. These numerical values do not accumulate. Two
@@ -253,7 +253,7 @@
 can add custom checks or even remove the Verified check entirely.
 
 Verification is typically an automated process using the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
 or a similar mechanism. However, there are still times when a change requires
 manual verification, or a reviewer needs to check how or if a change works.
 To accommodate these and other similar circumstances, Gerrit exposes each change
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 8a3b10e..f13bc22 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -316,9 +316,9 @@
 
 A useful feature on labels is the possibility to automatically copy
 scores forward to new patch sets if it was a
-link:config-labels.html#label_copyAllScoresOnTrivialRebase[trivial
-rebase] or if link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-there was no code change] (e.g. only the commit message was edited).
+link:config-labels.html#trivial_rebase[trivial rebase] or if
+link:config-labels.html#no_code_change[there was no code change] (e.g.
+only the commit message was edited).
 
 [[submit-rules]]
 == Submit Rules
@@ -374,7 +374,7 @@
 There are several solutions for integrating continuous integration
 systems. The most commonly used are:
 
-- link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
+- link:https://plugins.jenkins.io/gerrit-trigger/[
   Gerrit Trigger,role=external,window=_blank] plugin for link:http://jenkins-ci.org/[Jenkins,role=external,window=_blank]
 
 - link:http://www.mediawiki.org/wiki/Continuous_integration/Zuul[
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index f619c99..7a042ba 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -368,6 +368,10 @@
 project-level]. The project dashboards can be seen in the web UI under
 `Projects` > `List` > <project-name> > `Dashboards`.
 
+All dashboards and search pages allow an action to be applied to multiple
+changes at once. Select the changes with the checkboxes on the left side and
+choose the action from the action bar at the top of the change section.
+
 [[submit]]
 == Submit a Change
 
@@ -459,7 +463,7 @@
 push permission] on the destination branch.
 
 The move operation will not update the change's parent and users will have
-to link:#rebase[rebase] the change. Also, merge commits cannot be moved.
+to link:#rebase[rebase] the change.
 
 [[abandon]]
 [[restore]]
@@ -662,19 +666,10 @@
 * If you have a series of private changes and share one with reviewers,
   the reviewers can also see the commits of the predecessor private
   changes through the commit parent relationship.
-
-[[ignore]]
-== 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.
-This can be useful when you are added as a reviewer to a change on which
-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 marked
-as 'Reviewed'. Marking a change as 'Reviewed' means it will not be highlighted
-in the dashboard, until a new patch set is uploaded.
+* Users who would have permission to access the change except for its
+  private status and knowledge of its commit ID (e.g. through CI logs
+  or build artifacts containing build numbers) can fetch the code
+  using the commit ID.
 
 [[inline-edit]]
 == Inline Edit
@@ -807,16 +802,21 @@
 +
 This setting controls the email notifications.
 +
-** `Enabled`:
-+
-Email notifications are enabled.
-+
 ** [[cc-me]]`Every comment`:
 +
 Email notifications are enabled and you get notified by email as CC
 on comments that you write yourself.
 +
-** `Disabled`:
+** `Only comments left by others`
++
+Email notifications are enabled for all activities excluding comments or
+reviews authored by you.
++
+** `Only when I am in the attention set`
++
+Email notifications are only sent if the recipient is in the attention set.
++
+** `None`:
 +
 Email notifications are disabled.
 
@@ -1014,7 +1014,7 @@
 inline comment ("Yeah, I see why, let me try again.").
 
 [[security-fixes]]
--- Security Fixes
+== Security Fixes
 
 If a security vulnerability is discovered you normally want to have an
 embargo about it until fixed releases have been made available. This
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ab79c8f..e2afbf5 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -2,6 +2,7 @@
 [[Apache2_0]]
 Apache2.0
 
+* fonts:material-icons
 * fonts:robotofonts
 
 [[Apache2_0_license]]
@@ -403,6 +404,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -1120,6 +1122,126 @@
 ----
 
 
+[[highlight_js]]
+highlight.js
+
+* highlight.js
+
+[[highlight_js_license]]
+----
+BSD 3-Clause 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 the copyright holder 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.
+
+----
+
+
+[[highlightjs-closure-templates]]
+highlightjs-closure-templates
+
+* highlightjs-closure-templates
+
+[[highlightjs-closure-templates_license]]
+----
+BSD 3-Clause 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 the copyright holder 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.
+
+----
+
+
+[[highlightjs-structured-text]]
+highlightjs-structured-text
+
+* highlightjs-structured-text
+
+[[highlightjs-structured-text_license]]
+----
+BSD 3-Clause 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:

+

+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 holder 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.
+
+----
+
+
 [[immer]]
 immer
 
@@ -1184,6 +1306,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+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.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+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 “Markdown” 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]]
 page
 
@@ -1491,6 +1667,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_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.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -1513,3 +1902,216 @@
 
 ----
 
+
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_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 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 86ff904..8ccbcab 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -32,7 +32,7 @@
 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
+Gerrit includes an SSH client (Apache SSHD), to support authenticated
 replication of changes to remote systems, such as for automatic
 updates of mirror servers, or realtime backups.
 
@@ -42,31 +42,30 @@
 [[Apache2_0]]
 Apache2.0
 
+* auto:auto-factory
 * auto:auto-value
 * auto:auto-value-annotations
 * auto:auto-value-gson
 * commons:codec
 * commons:compress
 * commons:dbcp
-* commons:lang
 * commons:lang3
 * commons:net
 * commons:pool
+* commons:text
 * commons:validator
 * dropwizard:dropwizard-core
 * errorprone:annotations
 * flogger:api
+* fonts:material-icons
 * 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:http
 * jetty:io
 * jetty:jmx
@@ -1114,22 +1113,6 @@
 ----
 
 
-[[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/).
-
-----
-
-
 [[flexmark]]
 flexmark
 
@@ -2394,43 +2377,6 @@
 ----
 
 
-[[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
 
@@ -3362,6 +3308,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -4079,6 +4026,126 @@
 ----
 
 
+[[highlight_js]]
+highlight.js
+
+* highlight.js
+
+[[highlight_js_license]]
+----
+BSD 3-Clause 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 the copyright holder 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.
+
+----
+
+
+[[highlightjs-closure-templates]]
+highlightjs-closure-templates
+
+* highlightjs-closure-templates
+
+[[highlightjs-closure-templates_license]]
+----
+BSD 3-Clause 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 the copyright holder 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.
+
+----
+
+
+[[highlightjs-structured-text]]
+highlightjs-structured-text
+
+* highlightjs-structured-text
+
+[[highlightjs-structured-text_license]]
+----
+BSD 3-Clause 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:

+

+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 holder 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.
+
+----
+
+
 [[immer]]
 immer
 
@@ -4143,6 +4210,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+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.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+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 “Markdown” 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]]
 page
 
@@ -4450,6 +4571,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_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.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -4473,6 +4807,219 @@
 ----
 
 
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_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 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index c45de05..13873ed 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
index ba370b1..e120fdd 100644
--- a/Documentation/logs.txt
+++ b/Documentation/logs.txt
@@ -56,6 +56,11 @@
   CPU time in kernel mode is `total_cpu - user_cpu`.
 * `memory`: memory allocated in bytes to execute command. -1 if the JVM does
   not support this metric.
+* `command status`: the overall result of the git command over HTTP. Currently
+   populated only for the transfer phase of `git-upload-pack` commands.
+   Possible values:
+** `-1`: The `git-upload-pack` transfer was ultimately not successful
+** `0`: The `git-upload-pack` transfer was ultimately successful
 
 Example:
 ```
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index d8b6250..70352dc 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -93,6 +93,9 @@
 ** `plugin`:
    The name of the plugin that performed the operation.
 
+Performance metrics can be enabled via the
+link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
+setting.
 
 === Pushes
 
@@ -453,6 +456,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index b376d6e..0e1dfd0 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -253,5 +253,5 @@
 
 In case of rollback from NoteDB to ReviewDB, all the meta refs and the
 sequence ref need to be removed.
-The [remove-notedb-refs.sh,role=external,window=_blank](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh)
+The link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh[remove-notedb-refs.sh,role=external,window=_blank]
 script has been written to automate this process.
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 4e93da1..e4cb5d0 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -10,6 +10,10 @@
 when a change page is loaded. Such a call would return a list of `Runs` and each
 run can contain a list of `Results`.
 
+`Results` messages will render as markdown. It follows the
+[CommonMark](https://commonmark.org/help/) spec except inline images and direct
+HTML are not rendered and kept as plaintext.
+
 The details of the ChecksApi are documented in the
 link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
 Note that this link points to the `master` branch and might thus reflect a
@@ -24,8 +28,8 @@
 Here are some examples of open source plugins that make use of the Checks API:
 
 * link:https://gerrit.googlesource.com/plugins/checks/+/master/gr-checks/plugin.js[Gerrit Checks Plugin]
-* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/master/src/main/resources/static/buildbucket.js[Chromium Buildbucket Plugin]
-* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/master/src/main/resources/static/chromium-coverage.js[Chromium Coverage Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/main/web/plugin.ts[Chromium Buildbucket Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/main/web/plugin.ts[Chromium Coverage Plugin]
 
 [[register]]
 == register
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index dc7986f..d7343c2 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -30,10 +30,6 @@
 });
 ```
 
-You can find more elaborate examples in the
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/[polygerrit-ui/app/samples/]
-directory of the source tree.
-
 [[low-level-api-concepts]]
 == Low-level DOM API concepts
 
@@ -95,31 +91,36 @@
 [[low-level-style]]
 === Styling DOM Elements
 
-A plugin may provide Polymer's
-https://polymer-library.polymer-project.org/3.0/docs/devguide/style-shadow-dom[style
-modules,role=external,window=_blank] to style individual endpoints using
-`plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
-as a standalone `<dom-module>` defined in the same .js file.
+Gerrit only offers customized CSS styling by setting
+link:https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_[custom_properties]
+(aka css variables).
 
-See
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/theme-plugin.js[samples/theme-plugin.js]
-for an example.
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/styles/themes/[app-theme.ts]
+for the list of available variables.
+
+You can just create `<style>` elements yourself and add them to the
+`document.head`, but for your convenience the Plugin API provides a simple
+`styleApi().insertCSSRule()` method for doing just that. Typically you would
+define a CSS rule for `html`, which is always applied, or for a specific theme
+such as `html.lightTheme`. 
 
 ``` js
-const styleElement = document.createElement('dom-module');
-styleElement.innerHTML =
- `<template>
-    <style>
-    html {
-      --primary-text-color: red;
-    }
-   </style>
- </template>`;
-
-styleElement.register('some-style-module');
-
 Gerrit.install(plugin => {
-  plugin.registerStyleModule('change-metadata', 'some-style-module');
+  plugin.styleApi().insertCSSRule(`
+    html {
+      --header-text-color: black;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.lightTheme {
+      --header-background-color: red;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.darkTheme {
+      --header-background-color: blue;
+    }
+  `);
 });
 ```
 
@@ -146,10 +147,6 @@
 binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
-See
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/bind-parameters.js[samples/bind-parameters.js]
-for an example.
-
 === hook
 `plugin.hook(endpointName, opt_options)`
 
@@ -166,11 +163,6 @@
 
 See link:pg-plugin-endpoints.html[endpoints].
 
-=== registerStyleModule
-`plugin.registerStyleModule(endpointName, moduleName)`
-
-See link:#low-level-style[above].
-
 === on
 Register a JavaScript callback to be invoked when events occur within
 the web interface. Signature
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index c16d0d4..dd82f27 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -132,6 +132,10 @@
 === settings-screen
 This endpoint is situated at the end of the body of the settings screen.
 
+=== profile
+This endpoint is situated at the top of the Profile section of the settings
+screen below the section description text.
+
 === reply-text
 This endpoint wraps the textarea in the reply dialog.
 
@@ -141,6 +145,11 @@
 === header-title
 This endpoint wraps the title-text in the application header.
 
+=== cherrypick-main
+This endpoint is located in the cherrypick dialog. It has two slots `top`
+and `bottom` and `changes` as a parameter with the list of changes (or
+just the one change) to be cherrypicked.
+
 === confirm-revert-change
 This endpoint is inside the confirm revert dialog. By default it displays a
 generic confirmation message regarding reverting the change. Plugins may add
@@ -228,3 +237,13 @@
 +
 current revision displayed, an instance of
 link:rest-api-changes.html#revision-info[RevisionInfo]
+
+=== account-status-icon
+The `account-status-icon` extension point adds an icon to all account chips and
+labels.
+
+In addition to default parameters, the following are available:
+
+* `accountId`
++
+the Id of the account that the status icon should correspond to.
\ No newline at end of file
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 586f685..560fb92 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -10,6 +10,10 @@
   -d <SITE_PATH>
   [--enable-httpd | --disable-httpd]
   [--enable-sshd | --disable-sshd]
+  [--debug]
+  [--debug-port]
+  [--debug_address]
+  [--suspend]
   [--console-log]
   [--replica]
   [--headless]
@@ -39,6 +43,17 @@
 	Enable (or disable) the internal SSH daemon, answering SSH
 	clients and remotely executed commands.  Enabled by default.
 
+--debug::
+	Start JVM in debug mode.
+
+--debug-port::
+--debug_address:
+	Specify which JVM debug port/address to use. The default debug address is 8000.
+
+--suspend::
+	Start JVM debug in suspended mode. The JVM will await for a debugger
+	to attach before proceeding with the start.
+
 --replica::
 	Run in replica mode, permitting only read operations
     by clients.  Commands which modify state such as
diff --git a/Documentation/pgm-reindex.txt b/Documentation/pgm-reindex.txt
index 5167277..0653d8d 100644
--- a/Documentation/pgm-reindex.txt
+++ b/Documentation/pgm-reindex.txt
@@ -20,7 +20,8 @@
 
 == OPTIONS
 --threads::
-	Number of threads to use for indexing.
+	Number of threads to use for indexing. Default is
+	link:config-gerrit.html#index.batchThreads[index.batchThreads]
 
 --changes-schema-version::
 	Schema version to reindex; default is most recent version.
@@ -35,6 +36,10 @@
 	Reindex only index with given name. This option can be supplied
 	more than once to reindex multiple indices.
 
+--disable-cache-stats::
+	Disables printing cache statistics at the end of program to reduce
+	noise. Defaulted when reindex is run from init on a new site.
+
 == CONTEXT
 The secondary index must be enabled. See
 link:config-gerrit.html#index.type[index.type].
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 45a39d8..991f36c 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -388,7 +388,7 @@
 |`revision`           ||
 The revision of the `refs/meta/config` branch from which the access
 rights were loaded.
-|`inherits_from`      |not set for the `All-Project` project|
+|`inherits_from`      |not set for the `All-Projects` project|
 The parent project from which permissions are inherited as a
 link:rest-api-projects.html#project-info[ProjectInfo] entity.
 |`local`              ||
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index ae0c0a6..7e06e4a 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1284,7 +1284,7 @@
   )]}'
   {
     "changes_per_page": 25,
-    "theme": "LIGHT",
+    "theme": "AUTO",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
@@ -1292,6 +1292,7 @@
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
+    "allow_browser_notifications": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1344,6 +1345,7 @@
     "size_bar_in_change_table": true,
     "disable_keyboard_shortcuts": true,
     "disable_token_highlighting": true,
+    "allow_browser_notifications": false,
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -2658,7 +2660,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        ||
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2686,6 +2688,8 @@
 |`signed_off_by`                |not set if `false`|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2729,7 +2733,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        |optional|
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2755,6 +2759,8 @@
 |`signed_off_by`                |optional|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           |optional|
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 33e7804..2f144c6 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -77,8 +77,8 @@
 link:user-search.html#_search_operators[query string] must be provided
 by the `q` parameter. The `n` parameter can be used to limit the
 returned results. The `no-limit` parameter can be used remove the default
-limit on queries and return all results. This might not be supported by
-all index backends.
+limit on queries and return all results (does not apply to anonymous requests).
+This might not be supported by all index backends.
 
 As result a list of link:#change-info[ChangeInfo] entries is returned.
 The change output is sorted by the last update time, most recently
@@ -103,15 +103,17 @@
       "id": "demo~master~Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "project": "demo",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2012-07-17 07:19:27.766000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2012-07-17 07:19:27.766000000",
+          "reason": "reviewer or cc replied"
         }
-      ]
+      },
       "change_id": "Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "subject": "One change",
       "status": "NEW",
@@ -224,19 +226,18 @@
 
 [[labels]]
 --
-* `LABELS`: a summary of each label required for submit, and
-  approvers that have granted (or rejected) with that label
-  as well as all reviewers by state, and reviewers that may
-  be removed by the current user.
+* `LABELS`: Includes the `labels` field which contains all information about
+labels and approvers that have granted (or rejected) that label. Does not
+include the `permitted_voting_range` attribute with the list of
+link:#approval-info[ApprovalInfo] of the `all` attribute.
 --
 
 [[detailed-labels]]
 --
-* `DETAILED_LABELS`: detailed label information, including numeric
-  values of all existing approvals, recognized label values, values
-  permitted to be set by any reviewer and the change owner, all
-  reviewers by state, and reviewers that may be removed by the
-  current user.
+* `DETAILED_LABELS`: Includes the `labels` field which contains all information
+about labels and approvers that have granted (or rejected) that label, including
+the `permitted_voting_range` attribute with the list of
+link:#approval-info[ApprovalInfo] of the `all` attribute.
 --
 
 [[submit-requirements]]
@@ -546,15 +547,17 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
-          "name": "John Doe"
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -613,15 +616,17 @@
   )]}'
   {
     "added": {
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2013-02-21 11:16:36.775000000",
+          "reason": "reviewer or cc replied"
         }
-      ]
+      },
       "updated": "2013-02-21 11:16:36.775000000",
       "topic": "new-topic"
     },
@@ -652,15 +657,17 @@
       "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
       "project": "myProject",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2013-02-21 11:16:36.775000000",
+          "reason": "reviewer or cc replied"
         }
-      ],
+      },
       "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
       "subject": "Implementing Feature X",
       "status": "NEW",
@@ -720,18 +727,18 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
           "_account_id": 1000096,
           "name": "John Doe",
           "email": "john.doe@example.com",
           "username": "jdoe"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -795,8 +802,8 @@
           }
         ]
         "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be submitted",
+          "-1": "I would prefer this is not submitted as is",
           " 0": "No score",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
@@ -817,6 +824,26 @@
         "+2"
       ]
     },
+    "removable_labels": {
+      "Code-Review": {
+        "-1": [
+          {
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com",
+            "username": "jdoe"
+          }
+        ],
+        "+1": [
+          {
+            "_account_id": 1000097,
+            "name": "Jane Roe",
+            "email": "jane.roe@example.com",
+            "username": "jroe"
+          }
+        ]
+      }
+    },
     "removable_reviewers": [
       {
         "_account_id": 1000096,
@@ -1506,6 +1533,223 @@
   The change could not be rebased due to a path conflict during merge.
 ----
 
+[[rebase-chain]]
+=== Rebase Chain
+--
+'POST /changes/link:#change-id[\{change-id\}]/rebase:chain'
+--
+
+Rebases an ancestry chain of changes.
+
+The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
+
+Requires a linear ancestry relation (single parenting throughout the chain).
+
+Optionally, the parent revision (of the oldest ancestor to be rebased) can be changed to another
+change, revision or branch through the link:#rebase-input[RebaseInput] entity.
+
+If the chain is outdated, i.e., there's a change that depends on an old revision of its parent, the
+result is the same as individually rebasing all outdated changes on top of their parent's latest
+revision before running the rebase chain action.
+
+.Request
+----
+  POST /changes/myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f/rebase:chain HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "base" : "1234",
+  }
+----
+
+As response a link:#rebase-chain-info[RebaseChainInfo] entity is returned that
+describes the rebased changes. Information about the current patch sets
+are included.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "rebased_changes": [
+      {
+        "id": "myProject~master~I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "subject": "456",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "a2a6692213f546e1045ecf4647439fac8d6d8faa",
+        "_number": 21,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+        "revisions": {
+          "c3b2ba222d42a56e05c90f88d4509a124620517d": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/21/21/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "7803f427dd7c4a2441466e4d740a1850dcee1af4",
+                  "subject": "123"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 49: 39.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "456",
+              "message": "456\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "id": "myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "subject": "789",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "3bfb843fea471f96e16b9199c3a30fff0285bc45",
+        "_number": 22,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
+        "revisions": {
+          "77eb17a9501a5c21963bc6af56085e60f281acbb": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/22/22/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+                  "subject": "456"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 51: 07.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "789",
+              "message": "789\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      }
+    ],
+  }
+----
+
+If the change cannot be rebased, e.g. due to conflicts, the response is
+"`409 Conflict`" and the error message is contained in the response
+body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change I0e534de9d7f0d6f35b71f7d726acf835b2110c66 could not be rebased due to a conflict during
+  merge.
+
+  merge conflict(s):
+  a.txt
+----
+
 [[move-change]]
 === Move Change
 --
@@ -1857,7 +2101,8 @@
 
 * The given change.
 * If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-  is enabled, include all open changes with the same topic.
+  is enabled OR if the `o=TOPIC_CLOSURE` query parameter is passed, include all
+  open changes with the same topic.
 * For each change whose submit type is not CHERRY_PICK, include unmerged
   ancestors targeting the same branch.
 
@@ -1884,7 +2129,7 @@
 
 Standard link:#query-options[formatting options] can be specified
 with the `o` parameter, as well as the `submitted_together` specific
-option `NON_VISIBLE_CHANGES`.
+options `NON_VISIBLE_CHANGES` and `TOPIC_CLOSURE`.
 
 .Response
 ----
@@ -1925,8 +2170,8 @@
             }
           ],
           "values": {
-            "-2": "This shall not be merged",
-            "-1": "I would prefer this is not merged as is",
+            "-2": "This shall not be submitted",
+            "-1": "I would prefer this is not submitted as is",
             " 0": "No score",
             "+1": "Looks good to me, but someone else must approve",
             "+2": "Looks good to me, approved"
@@ -2022,8 +2267,8 @@
             }
           ],
           "values": {
-            "-2": "This shall not be merged",
-            "-1": "I would prefer this is not merged as is",
+            "-2": "This shall not be submitted",
+            "-1": "I would prefer this is not submitted as is",
             " 0": "No score",
             "+1": "Looks good to me, but someone else must approve",
             "+2": "Looks good to me, approved"
@@ -2124,6 +2369,66 @@
   HTTP/1.1 204 No Content
 ----
 
+[[apply-patch]]
+=== Create patch-set from patch
+--
+'POST /changes/link:#change-id[\{change-id\}]/patch:apply'
+--
+
+Creates a new patch set on a destination change from the provided patch.
+
+The patch must be provided in the request body, inside a
+link:#applypatchpatchset-input[ApplyPatchPatchSetInput] entity.
+
+If a base commit is given, the patch is applied on top of it. Otherwise, the
+patch is applied on top of the target change's branch tip.
+
+Applying the patch will fail if the destination change is closed, or in case of any conflicts.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/patch:apply HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "patch": {
+      "patch": "new file mode 100644\n--- /dev/null\n+++ b/a_new_file.txt\n@@ -0,0 +1,2 @@ \
++Patch compatible `git diff` output \
++For example: `link:#get-patch[<gerrit patch>] | base64 -d | sed -z 's/\n/\\n/g'`"
+    }
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the destination change after applying the patch.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Original change subject",
+    "status": "NEW",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "mergeable": true,
+    "insertions": 12,
+    "deletions": 11,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    },
+    "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c"
+  }
+----
+
 [[get-included-in]]
 === Get Included In
 --
@@ -2579,35 +2884,6 @@
   }
 ----
 
-[[ignore]]
-=== Ignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/ignore'
---
-
-Marks a change as ignored. The change will not be shown in the incoming
-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
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ignore HTTP/1.0
-----
-
-[[unignore]]
-=== Unignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/unignore'
---
-
-Un-marks a change as ignored.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
-----
-
 [[get-hashtags]]
 === Get Hashtags
 --
@@ -2825,8 +3101,18 @@
 --
 
 Tests a submit requirement and returns the result as a
-link:#submit-requirement-result-info[SubmitRequirementResultInfo]. The request
-body must contain a link:#submit-requirement-input[SubmitRequirementInput].
+link:#submit-requirement-result-info[SubmitRequirementResultInfo].
+
+The submit requirement can be specified in one of the following ways:
+
+  * In the request body as a link:#submit-requirement-input[SubmitRequirementInput].
+  * By passing the two request parameters `sr-name` and
+ `refs-config-change-id`. The submit requirement will then be loaded from
+ the project config pointed to by the latest patchset of this change ID.
+ The `sr-name` should point to an existing submit-requirement and the
+ `refs-config-change-id` must be a valid change identifier for a change in the
+ refs/meta/config branch, otherwise the request would fail with
+ `400 Bad Request`.
 
 Note that this endpoint does not modify the change resource.
 
@@ -2946,12 +3232,19 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
+    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==",
+    "file_mode": 100755
   }
 ----
 
 Note that it must be base-64 encoded data uri.
 
+The "file_mode" field is optional, and if provided must be in octal format. The field
+indicates whether the file is executable or not and has a value of either 100755
+(executable) or 100644 (not executable). If it's unset, this indicates no change
+has been made. New files default to not being executable if this parameter is not
+provided
+
 When change edit doesn't exist for this change yet it is created. When file
 content isn't provided, it is wiped out for that file. As response
 "`204 No Content`" is returned.
@@ -3973,8 +4266,8 @@
           }
         ]
         "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be submitted",
+          "-1": "I would prefer this is not submitted as is",
           " 0": "No score",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
@@ -4047,6 +4340,16 @@
 Retrieves related changes of a revision.  Related changes are changes that either
 depend on, or are dependencies of the revision.
 
+Additional fields can be obtained by adding `o` parameters. Since these may slow
+down processing they are disabled by default. Currently a single parameter is
+supported:
+
+[[get-related-changes-submittable]]
+--
+* `SUBMITTABLE`: Compute the `submittable` field in the returned
+  link:#related-change-and-commit-info[RelatedChangeAndCommitInfo] entities.
+--
+
 .Request
 ----
   GET /changes/gerrit~master~I5e4fc08ce34d33c090c9e0bf320de1b17309f774/revisions/b1cb4caa6be46d12b94c25aa68aebabcbb3f53fe/related HTTP/1.0
@@ -4581,60 +4884,6 @@
 If the `path` parameter is set, the returned content is a diff of the single
 file that the path refers to.
 
-[[submit-preview]]
-=== Submit Preview
---
-'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/preview_submit'
---
-Gets a file containing thin bundles of all modified projects if this
-change was submitted. The bundles are named `${ProjectName}.git`.
-Each thin bundle contains enough to construct the state in which a project would
-be in if this change were submitted. The base of the thin bundles are the
-current target branches, so to make use of this call in a non-racy way, first
-get the bundles and then fetch all projects contained in the bundle.
-(This assumes no non-fastforward pushes).
-
-You need to give a parameter '?format=zip' or '?format=tar' to specify the
-format for the outer container. It is always possible to use tgz, even if
-tgz is not in the list of allowed archive formats.
-
-To make good use of this call, you would roughly need code as found at:
-----
- $ curl -Lo preview_submit_test.sh http://review.example.com:8080/tools/scripts/preview_submit_test.sh
-----
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/preview_submit?zip HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Date: Tue, 13 Sep 2016 19:13:46 GMT
-  Content-Disposition: attachment; filename="submit-preview-147.zip"
-  X-Content-Type-Options: nosniff
-  Cache-Control: no-cache, no-store, max-age=0, must-revalidate
-  Pragma: no-cache
-  Expires: Mon, 01 Jan 1990 00:00:00 GMT
-  Content-Type: application/x-zip
-  Transfer-Encoding: chunked
-
-  [binary stuff]
-----
-
-In case of an error, the response is not a zip file but a regular json response,
-containing only the error message:
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  "Anonymous users cannot submit"
-----
-
 [[get-mergeable]]
 === Get Mergeable
 --
@@ -5084,6 +5333,9 @@
 with a new message, which contains the name of the user who deletes
 the comment and the reason why it's deleted.
 
+This endpoint also marks the comment as resolved since deleted comments are not
+actionable.
+
 Note that only users with the
 link:access-control.html#capability_administrateServer[Administrate Server]
 global capability are permitted to delete a comment.
@@ -5224,8 +5476,8 @@
   }
 ----
 
-[[get-ported-comments]]
-=== Get Ported Comments
+[[list-ported-comments]]
+=== List Ported Comments
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_comments'
 --
@@ -5308,24 +5560,24 @@
   }
 ----
 
-[[get-ported-drafts]]
-=== Get Ported Drafts
+[[list-ported-drafts]]
+=== List Ported Drafts
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_drafts'
 --
 
 Ports draft comments of other revisions to the requested revision.
 
-This endpoint behaves similarly to the link:#get-ported-comments[Get Ported Comments] endpoint.
+This endpoint behaves similarly to the link:#list-ported-comments[List Ported Comments] endpoint.
 With this endpoint, only draft comments of the calling user are ported, though. If a draft comment
 is a reply to a published comment, only the ported draft comment is returned.
 
 Depending on the filtering rules, it's possible that this endpoint returns a draft comment which is
 a reply to a comment thread which is not returned by the
-link:#get-ported-comments[Get Ported Comments] endpoint. That's intended behavior. Callers must be
+link:#list-ported-comments[List Ported Comments] endpoint. That's intended behavior. Callers must be
 able to handle this situation. The same holds for drafts which are a reply to a robot comment.
 
-Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
+Different than the link:#list-ported-comments[List Ported Comments] endpoint, the `author` of the
 returned comments is not filled for this endpoint as only comments of the calling user are returned.
 
 This endpoint requires authentication.
@@ -5357,8 +5609,8 @@
   }
 ----
 
-[[apply-fix]]
-=== Apply Fix
+[[apply-stored-fix]]
+=== Apply Stored Fix
 --
 'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/apply'
 --
@@ -5425,6 +5677,101 @@
   The existing change edit could not be merged with another tree.
 ----
 
+[[apply-provided-fix]]
+==== Apply Provided Fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:apply'
+--
+Applies a list of <<fix-replacement-info,FixReplacementInfo>> loaded from the
+<<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the request body. The
+application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
+patchset.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fix:apply HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `ApplyProvidedFixInput` was successfully applied, an link:#edit-info[EditInfo] describing the
+resulting change edit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": {
+      "commit": "bd43e48c33d2b1a03485040eba38cefc505f7997",
+      "parents": [
+        {
+          "commit": "9825962f8ab6da89afebad3f5034db05fb4b7560"
+        }
+      ],
+      "author": {
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "date": "2022-05-07 15:21:27.000000000",
+          "tz": 120
+      },
+      "committer": {
+           "name": "Jane Doe",
+           "email": "jane.doe@example.com",
+           "date": "2022-05-07 15:35:43.000000000",
+           "tz": 120
+      },
+      "subject": "Implement feature X",
+      "message": "Implement feature X\n\nWith this feature ..."
+    },
+    "base_patch_set_number": 1,
+    "base_revision": "86d87686ce0ef7f7c536bfc7e9a66f5a6fa5d658",
+    "ref": "refs/users/01/1000001/edit-1/1"
+  }
+----
+
+If the application failed due to presence of an existing change edit,
+the response "`400 Bad Request`" including an error message in the response body
+is returned.
+
+.Response
+----
+  HTTP/1.1 400 Bad Request
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change edit already exists. A new change edit can't be created
+----
+
+If the application failed due to application on a previous patch set, the response
+"`409 Conflict`" including an error message in the response body is returned.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  A change edit may only be created for the current patch set 1,2 (and not for 1,1)
+----
+
 [[list-files]]
 === List Files
 --
@@ -5781,8 +6128,8 @@
 differences are reported in the result.  Valid values are `IGNORE_NONE`,
 `IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` or `IGNORE_ALL`.
 
-[[preview-fix]]
-=== Preview fix
+[[preview-stored-fix]]
+=== Preview Stored Fix
 --
 'GET /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/preview'
 --
@@ -5792,6 +6139,102 @@
 
 Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id and a virtual patch set with the applied fix.
 
+[[preview-provided-fix]]
+=== Preview Provided fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:preview'
+--
+
+Gets the diffs of all files for a list of <<fix-replacement-info,FixReplacementInfo>> loaded from
+the <<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the
+request body.
+As response, a map of link:#diff-info[DiffInfo] is returned that describes the diffs.
+
+Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id
+and a virtual patch set with the applied fix. No content on the server will be modified as part of this request.
+
+.Request
+----
+  POST /changes/myProject~master~Id6f0b9d946791f8aba90ace53074eda565983452/revisions/1/fix:preview HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `Preview Provided Fix` operation was successful, a link:#diff-info[DiffInfo] previewing the
+change is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "abcd.txt": {
+      "meta_a": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "meta_b": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "intraline_status": "OK",
+      "change_type": "MODIFIED",
+      "content": [
+        {
+          "ab": [
+            "ABCDEFGHI"
+          ]
+        },
+        {
+          "a": [
+            "JKLMNOPQR"
+          ],
+          "b": [
+            "JKabcdefgOPQR"
+          ],
+          "edit_a": [
+            [
+              2,
+              3
+            ]
+          ],
+          "edit_b": [
+            [
+              2,
+              7
+            ]
+          ]
+        },
+        {
+          "ab": [
+            ""
+          ]
+        }
+      ]
+    }
+  }
+----
+
+
 [[get-blame]]
 === Get Blame
 --
@@ -6322,7 +6765,7 @@
 * a commit ID ("674ac754f91e64a0efb8087e59a176484bd534d1")
 * an abbreviated commit ID that uniquely identifies one revision of the
   change ("674ac754"), at least 4 digits are required
-* a legacy numeric patch number ("1" for first patch set of the change)
+* a numeric patch number ("1" for first patch set of the change)
 * "0" or the literal `edit` for a change edit
 
 [[json-entities]]
@@ -6375,6 +6818,46 @@
 at the server or permissions are modified. Not present if false.
 |====================================
 
+[[applypatch-input]]
+=== ApplyPatchInput
+The `ApplyPatchInput` entity contains information about a patch to apply.
+
+A new commit will be created from the patch, and saved as a new patch set.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name          ||Description
+|`patch`             |required|
+The patch to be applied. Must be compatible with `git diff` output.
+For example, link:#get-patch[Get Patch] output.
+|=================================
+
+[[applypatchpatchset-input]]
+=== ApplyPatchPatchSetInput
+The `ApplyPatchPatchSetInput` entity contains information for creating a new patch set from a
+given patch.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name           ||Description
+|`patch`              |required|
+The details of the patch to be applied as a link:#applypatch-input[ApplyPatchInput] entity.
+|`commit_message`     |optional|
+The commit message for the new patch set. If not specified, a predefined message will be used.
+|`base`               |optional|
+40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch set.
+If set, it must be a merged commit or a change revision on the destination branch.
+Otherwise, the target change's branch tip will be used.
+|`author`             |optional|
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
+The caller needs "Forge Author" permission when using this field, unless specifies their own details.
+This field does not affect the owner of the change, which will continue to use the identity of the
+caller.
+|=================================
+
+
 [[approval-info]]
 === ApprovalInfo
 The `ApprovalInfo` entity contains information about an approval from a
@@ -6520,7 +7003,14 @@
 |`topic`              |optional|The topic to which this change belongs.
 |`attention_set`      |optional|
 The map that maps link:rest-api-accounts.html#account-id[account IDs]
-to link:#attention-set-info[AttentionSetInfo] of that account.
+to link:#attention-set-info[AttentionSetInfo] of that account. Those are all
+accounts that are currently in the attention set.
+|`removed_from_attention_set`      |optional|
+The map that maps link:rest-api-accounts.html#account-id[account IDs]
+to link:#attention-set-info[AttentionSetInfo] of that account. Those are all
+accounts that were in the attention set but were removed. The
+link:#attention-set-info[AttentionSetInfo] is the latest and most recent removal
+ of the account from the attention set.
 |`assignee`           |optional|
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
@@ -6601,6 +7091,13 @@
 A map of the permitted labels that maps a label name to the list of
 values that are allowed for that label. +
 Only set if link:#detailed-labels[detailed labels] are requested.
+|`removable_labels`   |optional|
+A map of the removable labels that maps a label name to the map of
+values and reviewers (
+link:rest-api-accounts.html#account-info[AccountInfo] entities)
+that are allowed to be removed from the change. +
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`removable_reviewers`|optional|
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
@@ -6685,7 +7182,8 @@
 by one of the following REST endpoints: link:#create-change[Create
 Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
 For Change], link:#cherry-pick[Cherry Pick Revision],
-link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit]
+link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
+link:#rebase-change[Rebase Change]
 |==================================
 
 [[change-input]]
@@ -6718,11 +7216,16 @@
 Whether the new change should be set to work in progress.
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
-change operation. Mutually exclusive with `base_commit`.
+change operation. +
+Mutually exclusive with `base_commit`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`base_commit`        |optional|
 A 40-digit hex SHA-1 of the commit which will be the parent commit of the newly
-created change. If set, it must be a merged commit on the destination branch.
-Mutually exclusive with `base_change`.
+created change. If set, it must be a merged commit on the destination branch. +
+Mutually exclusive with `base_change`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`. Using this option is
 only possible for non-merge commits (if the `merge` field is not set).
@@ -6739,10 +7242,12 @@
 If set, the target branch (see  `branch` field) must exist (it is not
 possible to create it automatically by setting the `new_branch` field
 to `true`.
+|`patch`              |optional|
+The detail of a patch to be applied as an link:#applypatch-input[ApplyPatchInput] entity.
 |`author`             |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
 The caller needs "Forge Author" permission when using this field.
 This field does not affect the owner of the change, which will
 continue to use the identity of the caller.
@@ -6798,28 +7303,28 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name         ||Description
-|`message`          |optional|
+|Field Name          ||Description
+|`message`           |optional|
 Commit message for the cherry-pick change. If not set, the commit message of
 the cherry-picked commit is used.
-|`destination`      ||Destination branch
-|`base`             |optional|
+|`destination`       ||Destination branch
+|`base`              |optional|
 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
 If set, it must be a merged commit or a change revision on the destination branch.
-|`parent`           |optional, defaults to 1|
+|`parent`            |optional, defaults to 1|
 Number of the parent relative to which the cherry-pick should be considered.
-|`notify`           |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 after the cherry-pick. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`   |optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`keep_reviewers`   |optional, defaults to false|
+|`keep_reviewers`    |optional, defaults to false|
 If `true`, carries reviewers and ccs over from original change to newly created one.
-|`allow_conflicts`  |optional, defaults to false|
+|`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.
@@ -6827,7 +7332,7 @@
 `contains_git_conflicts` field in the link:#change-info[ChangeInfo]. If
 there are conflicts the cherry-pick change is marked as
 work-in-progress.
-|`topic`            |optional|
+|`topic`             |optional|
 The topic of the created cherry-picked change. If not set, the default depends
 on the source. If the source is a change with a topic, the resulting topic
 of the cherry-picked change will be {source_change_topic}-{destination_branch}.
@@ -6835,10 +7340,18 @@
 the created change will have no topic.
 If the change already exists, the topic will not change if not set. If set, the
 topic will be overridden.
-|`allow_empty`      |optional, defaults to false|
+|`allow_empty`       |optional, defaults to false|
 If `true`, the cherry-pick succeeds also if the created commit will be empty.
 If `false`, a cherry-pick that would create an empty commit fails without creating
 the commit.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |===========================
 
 [[comment-info]]
@@ -7104,6 +7617,12 @@
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
+|`ignore_automatic_attention_set_rules`|optional|
+If set to true, ignore all automatic attention set rules described in the
+link:#attention-set[attention set]. When not set, the default is false.
+|`reason`         |optional|
+The reason why this vote is deleted. Will +
+go into the change message.
 |=============================
 
 [[description-input]]
@@ -7155,7 +7674,11 @@
 |==========================
 |Field Name    ||Description
 |`name`        ||The name of the file.
-|`content_type`||The content type of the file.
+|`content_type`||The content type of the file. For the commit message and merge
+list the value is `text/x-gerrit-commit-message` and `text/x-gerrit-merge-list`
+respectively. For git links the value is `x-git/gitlink`. For symlinks the value
+is `x-git/symlink`. For regular files the value is the file mime type (e.g.
+`text/x-java`, `text/x-c++src`, etc...).
 |`lines`       ||The total number of lines in the file.
 |`web_links`   |optional|
 Links to the file in external sites as a list of
@@ -7227,6 +7750,16 @@
 Whether the web link should be shown on the unified diff screen.
 |=======================
 
+[[apply-provided-fix-input]]
+=== ApplyProvidedFixInput
+The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
+
+[options="header",cols="1,6"]
+|=======================
+|Field Name              |Description
+|`fix_replacement_infos` |A list of <<fix-replacement-info,FixReplacementInfo>>.
+|=======================
+
 [[edit-file-info]]
 === EditFileInfo
 The `EditFileInfo` entity contains additional information
@@ -7296,16 +7829,21 @@
 Number of inserted lines. +
 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>>.
+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. +
 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>>.
+differ by one from details provided in <<diff-info,DiffInfo>>.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
-|`size`          ||
-File size in bytes.
+|`size`          || File size in bytes.
+|`old_mode`        |optional|File mode in octal (e.g. 100644) at the old commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For added files, this field will not be present.
+|`new_mode`        |optional|File mode in octal (e.g. 100644) at the new commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For deleted files, this field will not be present.
 |=============================
 
 [[fix-input]]
@@ -7424,11 +7962,10 @@
 There are two options that control the contents of `LabelInfo`:
 link:#labels[`LABELS`] and link:#detailed-labels[`DETAILED_LABELS`].
 
-* For a quick summary of the state of labels, use `LABELS`.
-* For detailed information about labels, including exact numeric votes for all
-  users and the allowed range of votes for the current user, use `DETAILED_LABELS`.
+* Using `LABELS` will skip the `all.permitted_voting_range` attribute.
+* Using `DETAILED_LABELS` will include the `all.permitted_voting_range`
+  attribute.
 
-==== Common fields
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name    ||Description
@@ -7436,12 +7973,7 @@
 Whether the label is optional. Optional means the label may be set, but
 it's neither necessary for submission nor does it block submission if
 set.
-|===========================
-
-==== Fields set by `LABELS`
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
+|`description` |optional| The description of the label.
 |`approved`    |optional|One user who approved this label on the change
 (voted the maximum value) as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity.
@@ -7461,16 +7993,14 @@
 "`+1`"/"`-1`".
 |`default_value`|optional|The default voting value for the label.
 This value may be outside the range specified in permitted_labels.
-|===========================
-
-==== Fields set by `DETAILED_LABELS`
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
+|votes|optional|A list of integers containing the vote values applied to this
+label at the latest patchset.
 |`all`         |optional|List of all approvals for this label as a list
 of link:#approval-info[ApprovalInfo] entities. Items in this list may
 not represent actual votes cast by users; if a user votes on any label,
-a corresponding ApprovalInfo will appear in this list for all labels.
+a corresponding ApprovalInfo will appear in this list for all labels. +
+The `permitted_voting_range` attribute is only set if the `DETAILED_LABELS`
+option is requested.
 |`values`      |optional|A map of all values that are allowed for this
 label. The map maps the values ("`-2`", "`-1`", " `0`", "`+1`", "`+2`")
 to the value descriptions.
@@ -7555,11 +8085,11 @@
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
 |`author`             |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
 The caller needs "Forge Author" permission when using this field.
-This field does not affect the owner of the change, which will
+This field does not affect the owner or the committer of the change, which will
 continue to use the identity of the caller.
 |==================================
 
@@ -7688,22 +8218,46 @@
 === RebaseInput
 The `RebaseInput` entity contains information for changing parent when rebasing.
 
-[options="header",width="50%",cols="1,^1,5"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`base`        |optional|
+|Field Name          ||Description
+|`base`              |optional|
 The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
 Alternatively, a change number can be specified, in which case the current
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
-|`allow_conflicts`|optional, defaults to false|
+|`allow_conflicts`   |optional, defaults to false|
 If `true`, the rebase also succeeds if there are conflicts. +
 If there are conflicts the file contents of the rebased patch set contain
 git conflict markers to indicate the conflicts. +
 Callers can find out whether there were conflicts by checking the
 `contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
 If there are conflicts the change is marked as work-in-progress.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
+|===========================
+
+[[rebase-chain-info]]
+=== RebaseChainInfo
+
+The `RebaseChainInfo` entity contains information about a chain of changes
+that were rebased.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name                ||Description
+|`rebased_changes`         ||List of the unsubmitted ancestors, as link:#change-info[ChangeInfo]
+entities. Includes both rebased changes, and previously up-to-date ancestors. The list is ordered by
+ancestry, where the oldest ancestor is the first.
+|`contains_git_conflicts`  ||Whether any of the rebased changes has conflicts
+due to rebasing.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -7724,6 +8278,9 @@
 |`_current_revision_number`|optional|The current revision number.
 |`status`                  |optional|The status of the change. The status of
 the change is one of (`NEW`, `MERGED`, `ABANDONED`).
+|`submittable`             |optional|Boolean indicating whether the change is
+submittable. +
+Only populated if link:#get-related-changes-submittable[requested].
 |===========================
 
 [[related-changes-info]]
@@ -7774,30 +8331,40 @@
 The `RevertInput` entity contains information for reverting a change.
 
 [options="header",cols="1,^1,5"]
-|=============================
-|Field Name      ||Description
-|`message`       |optional|
+|=================================
+|Field Name          ||Description
+|`message`           |optional|
 Message to be added as review comment to the change when reverting the
 change.
-|`notify`        |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 for reverting the change. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`|optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the revert as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`topic`         |optional|
+|`topic`             |optional|
 Name of the topic for the revert change. If not set, the default for Revert
 endpoint is the topic of the change being reverted, and the default for the
 RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 Topic can't contain quotation marks.
-|`work_in_progress` |optional|
+|`work_in_progress`  |optional|
 When present, change is marked as Work In Progress. The `notify` input is
-used if it's present, otherwise it will be overridden to `OWNER`. +
+used if it's present, otherwise it will be overridden to `NONE`. +
+Notifications for the reverted change will only sent once the result change is
+no longer WIP. +
 If not set, the default is false.
-|=============================
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
+|=================================
 
 [[revert-submission-info]]
 === RevertSubmissionInfo
@@ -7943,6 +8510,8 @@
 |`ready`                  |optional|
 If true, the change was moved from WIP to ready for review as a result of this
 action. Not set if false.
+|`error`                  |optional|
+Error message for non-200 responses.
 |============================
 
 [[reviewer-info]]
@@ -8183,7 +8752,13 @@
 Notify handling that defines to whom email notifications should be sent after
 the change is submitted. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `ALL`.
+If not set, the default is `ALL`.+
+Ignored if a post approval diff is present (i.e. if the change is submitted
+with copied approvals), because in this case everyone should be informed
+about the non-reviewed diff which has been applied after the change has been
+approved so that they can take action if the post approval diff looks
+unexpected. In other words if a post approval diff is present `ALL` is
+enforced.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
@@ -8271,6 +8846,13 @@
 `branch:refs/heads/foo and label:verified=+1`.
 |`fulfilled`||
 True if the submit requirement is fulfilled for the change.
+|`status`||
+A string containing the status of evaluating the expression which can be one
+of the following: +
+  * `PASS` - expression was evaluated and result is true. +
+  * `FAIL` - expression was evaluated and result is false. +
+  * `ERROR` - an error occurred while evaluating the expression. +
+  * `NOT_EVALUATED` - expression was not evaluated.
 |`passing_atoms`|optional|
 A list of passing atoms as strings. For the above expression,
 `passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
@@ -8278,6 +8860,9 @@
 |`failing_atoms`|optional|
 A list of failing atoms. This is similar to `passing_atoms` except that it
 contains the list of predicates that are not fulfilled for the change.
+|`error_message`|optional|
+If the submit requirement fails during evaluation, this string will contain
+an error message describing why it failed.
 |===========================
 
 [[submit-requirement-input]]
@@ -8320,7 +8905,8 @@
 Description of the submit requirement.
 |`status`||
 Status describing the result of evaluating the submit requirement. The status
-is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`).
+is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`,
+`FORCED`).
 |`is_legacy`||
 If true, this submit requirement result was created from a legacy
 link:#submit-record[SubmitRecord]. Otherwise, it was created by evaluating a
@@ -8333,11 +8919,15 @@
 omitted for the `applicability_expression_result`.
 |`submittability_expression_result`||
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
-containing the result of evaluating the submittability expression.
+containing the result of evaluating the submittability expression. +
+If the submit requirement does not apply, the `status` field of the result
+will be set to `NOT_EVALUATED`.
 |`override_expression_result`|optional|
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
-containing the result of evaluating the override expression. Not set if the
-submit requirement did not define an override expression.
+containing the result of evaluating the override expression. +
+Not set if the submit requirement did not define an override expression. If the
+submit requirement does not apply, the `status` field of the result will be set
+to `NOT_EVALUATED`.
 |===========================
 
 [[submitted-together-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index bd93b8b..505def0 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1380,7 +1380,14 @@
   POST /config/server/index.changes HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
-  {changes: ["foo~101", "bar~202"]}
+  {
+    "changes": [
+      "foo~101",
+      "bar~202",
+      "303"
+    ],
+    "delete_missing": "true"
+  }
 ----
 
 .Response
@@ -1389,6 +1396,9 @@
   Content-Disposition: attachment
 ----
 
+When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
+will be deleted in the index.
+
 
 [[ids]]
 == IDs
@@ -1876,6 +1886,10 @@
 |Field Name         ||Description
 |`changes`   ||
 List of link:rest-api-changes.html#change-id[change-ids]
+|`delete_missing`  |optional|
+Delete changes which are missing in NoteDb from the index. This can be used
+to get rid of stale index entries. Possible values are `true` and `false`.
+By default set to `false`.
 |================================
 
 [[jvm-summary-info]]
@@ -2008,6 +2022,10 @@
 |`default_theme`           |optional|
 URL to a default Gerrit UI theme plugin, if available.
 Located in `/static/gerrit-theme.js` by default.
+|`submit_requirement_dashboard_columns` ||
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard. If empty, the default is to display all submit
+requirements that are applicable for changes appearing in the dashboard.
 |=======================================
 
 [[sshd-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 92c6dbf..9e71df7 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -402,12 +402,12 @@
   Content-Type: application/json; charset=UTF-8
 
   )]}'
-  {
-    "test": {
+  [
+    {
       "id": "test",
       "description": "\u003chtml\u003e is escaped"
     }
-  }
+  ]
 ----
 
 [[project-query-limit]]
@@ -421,11 +421,11 @@
 ----
 
 The `/projects/` URL also accepts a start integer in the `start`
-parameter. The results will skip `start` groups from project list.
+parameter. The results will skip `start` projects from project list.
 
 Query 25 projects starting from index 50.
 ----
-  GET /groups/?query=<query>&limit=25&start=50 HTTP/1.0
+  GET /projects/?query=<query>&limit=25&start=50 HTTP/1.0
 ----
 
 [[project-query-options]]
@@ -1541,6 +1541,47 @@
   }
 ----
 
+[[commits-included-in]]
+=== Get Commits Included In Refs
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits:in'
+--
+
+Gets refs in which the specified commits were merged into. Returns a map of commits
+to sets of full ref names.
+
+One or more `commit` query parameters are required and each specifies a
+commit-id (SHA-1 in 40 digit hex representation). Commits will not be contained
+in the map if they do not exist or are not reachable from visible, specified refs.
+
+One or more `ref` query parameters are required and each specifies a full ref name.
+Refs which are not visible to the calling user according to the project's read
+permissions and refs which do not exist will be filtered out from the result.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/commits:in?commit=a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96&commit=6d2a3adb10e844c33617fc948dbeb88e868d396e&ref=refs/heads/master&ref=refs/heads/branch1 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96": [
+      "refs/heads/master"
+    ],
+    "6d2a3adb10e844c33617fc948dbeb88e868d396e": [
+      "refs/heads/branch1",
+      "refs/heads/master"
+    ]
+  }
+
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -3031,16 +3072,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     }
   ]
@@ -3078,16 +3117,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     },
     {
@@ -3096,14 +3133,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_any_score": true,
+      "copy_condition": "is:ANY",
       "allow_post_submit": true
     }
   ]
@@ -3141,16 +3178,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true
   }
 ----
@@ -3178,8 +3213,8 @@
     "commit_message": "Create Foo Label",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     }
@@ -3202,14 +3237,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_all_scores_if_no_change": true,
+    "copy_condition": "changekind:NO_CHANGE",
     "allow_post_submit": true
   }
 ----
@@ -3254,16 +3289,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true,
     "ignore_self_approval": true
   }
@@ -3336,8 +3369,8 @@
         "name": "Foo-Review",
         "values": {
           " 0": "No score",
-          "-1": "I would prefer this is not merged as is",
-          "-2": "This shall not be merged",
+          "-1": "I would prefer this is not submitted as is",
+          "-2": "This shall not be submitted",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
       }
@@ -3347,7 +3380,7 @@
         "function": "MaxWithBlock"
       },
       "Baz-Review": {
-        "copy_min_score": true
+        "copy_condition": "is:MIN"
       }
     }
   }
@@ -3360,6 +3393,198 @@
   HTTP/1.1 200 OK
 ----
 
+[[submit-requirement-endpoints]]
+== Submit Requirement Endpoints
+
+[[create-submit-requirement]]
+=== Create Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Creates a new submit requirement definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a submit requirement with this name is already defined in this project, this
+submit requirement definition is updated
+(see link:#update-submit-requirement[Update Submit Requirement]).
+
+The submit requirement data must be provided in the request body as
+link:#submit-requirement-input[SubmitRequirementInput].
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[update-submit-requirement]]
+=== Update Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Updates the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The new submit requirement will overwrite the existing submit requirement.
+That is, unspecified attributes will be set to their defaults.
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[get-submit-requirement]]
+=== Get Submit Requirement
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Retrieves the definition of a submit requirement that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirement/Code-Review HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[list-submit-requirements]]
+=== List Submit Requirements
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements'
+--
+
+Retrieves a list of all submit requirements for this project. The `inherited`
+parameter can be supplied to also list submit requirements from parent projects.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project. If the `inherited` option is used, the caller must have read access to
+the `refs/meta/config` branch of all parent projects as well.
+
+As response a list of link:#submit-requirement-info[SubmitRequirementInfo]
+entities is returned.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirements HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+    }
+  ]
+----
+
+[[delete-submit-requirement]]
+=== Delete Submit Requirement
+--
+'DELETE /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Deletes the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project. The request would fail with `404 Not Found` if there is no submit
+requirement with this name for this project.
+
+No request body is needed.
+
+.Request
+----
+  DELETE /projects/My-Project/submit_requirements/Foo-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+----
+
+If a submit requirement was deleted the response is "`204 No Content`".
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
 
 [[ids]]
 == IDs
@@ -3388,6 +3613,11 @@
 === \{label-name\}
 The name of a review label.
 
+[[submit-requirement-name]]
+=== \{submit-requirement-name\}
+The name of a submit requirement.
+
+
 [[project-name]]
 === \{project-name\}
 The name of the project.
@@ -3506,14 +3736,22 @@
 
 [options="header",cols="1,^2,4"]
 |=======================
-|Field Name||Description
-|`ref`     |optional|
+|Field Name          ||Description
+|`ref`               |optional|
 The name of the branch. The prefix `refs/heads/` can be
 omitted. +
 If set, must match the branch ID in the URL.
-|`revision`|optional|
+|`revision`          |optional|
 The base revision of the new branch. +
 If not set, `HEAD` will be used as base revision.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the ref operation
+validation listeners (e.g. can be used to skip certain validations). Which
+validation options are supported depends on the installed ref operation
+validation listeners. Gerrit core doesn't support any validation options, but
+ref operation validation listeners that are implemented in plugins may. Please
+refer to the documentation of the installed plugins to learn whether they
+support validation options. Unknown validation options are silently ignored.
 |=======================
 
 [[check-project-input]]
@@ -3560,16 +3798,19 @@
 |`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].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the 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.
+|==================================================
 
 [[commentlink-input]]
 === CommentLinkInput
 The `CommentLinkInput` entity describes the input for a
 link:config-gerrit.html#commentlink[commentlink].
 
-|==================================================
 [options="header",cols="1,^2,4"]
 |==================================================
 |Field Name |        |Description
@@ -3579,6 +3820,9 @@
 |`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].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the 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.
@@ -3652,17 +3896,19 @@
 Map with the comment link configurations of the project. The name of
 the comment link configuration is mapped to a link:#commentlink-info[
 CommentlinkInfo] entity.
-|`plugin_config`                           |optional|
+|`plugin_config`                                    |optional|
 Plugin configuration as map which maps the plugin name to a map of
 parameter names to link:#config-parameter-info[ConfigParameterInfo]
 entities. Only filled for users who have read access to `refs/meta/config`.
-|`actions`                                 |optional|
+|`actions`                                          |optional|
 Actions the caller might be able to perform on this project. The
 information is a map of view names to
-|`reject_empty_commit`                     |optional|
+|`reject_empty_commit`                              |optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 empty commits should be rejected when a change is merged.
 link:rest-api-changes.html#action-info[ActionInfo] entities.
+|`skip_adding_author_and_committer_as_reviewers`    |optional|
+Whether to skip adding the Git commit author and committer as reviewers for a new change.
 |=======================================================
 
 [[config-input]]
@@ -3929,7 +4175,7 @@
 |`value`            ||
 The effective boolean value.
 |`configured_value` ||
-The configured value, can be `TRUE`, `FALSE` or `INHERITED`.
+The configured value, can be `TRUE`, `FALSE` or `INHERIT`.
 |`inherited_value`  |optional|
 The boolean value inherited from the parent. +
 Not set if there is no parent.
@@ -3945,6 +4191,8 @@
 |Field Name      ||Description
 |`name`          ||
 The link:config-labels.html#label_name[name] of the label.
+|`description`   |optional|
+The description of the label.
 |`project_name`  ||
 The name of the project in which this label is defined.
 |`function`      ||
@@ -3964,34 +4212,8 @@
 |`can_override`  |`false` if not set|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|`false` if not set|
-Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
-label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
-|`copy_min_score`|`false` if not set|
-Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
-label.
-|`copy_max_score`|`false` if not set|
-Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
-label.
-|`copy_all_scores_if_no_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfNoChange[
-copyAllScoresIfNoChange] is set on the label.
-|`copy_all_scores_if_no_code_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`copy_all_scores_on_trivial_rebase`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
-copyAllScoresOnTrivialRebase] is set on the label.
-|`copy_all_scores_if_list_of_files_did_not_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
-copyAllScoresIfListOfFilesDidNotChange] is set on the label.
-|`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
-copyAllScoresOnMergeFirstParentUpdate] is set on the label.
-|`copy_values`   |optional|
-List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|`false` if not set|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4018,6 +4240,8 @@
 For label creation the name is required if this `LabelDefinitionInput` entity
 is contained in a link:#batch-label-input[BatchLabelInput]
 entity.
+|`description`          |optional|
+The new description for the label.
 |`function`      |optional|
 The new link:config-labels.html#label_function[function] of the label (can be
 `MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
@@ -4036,36 +4260,10 @@
 |`can_override`  |optional|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|optional|
-Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
-label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
 |`unset_copy_condition`|optional|
 If true, clears the value stored in `copy_condition`.
-|`copy_min_score`|optional|
-Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
-label.
-|`copy_max_score`|optional|
-Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
-label.
-|`copy_all_scores_if_no_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoChange[
-copyAllScoresIfNoChange] is set on the label.
-|`copy_all_scores_if_no_code_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`copy_all_scores_on_trivial_rebase`|optional|
-Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
-copyAllScoresOnTrivialRebase] is set on the label.
-|`copy_all_scores_if_list_of_files_did_not_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
-copyAllScoresIfListOfFilesDidNotChange] is set on the label.
-|`copy_all_scores_on_merge_first_parent_update`|optional|
-Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
-copyAllScoresOnMergeFirstParentUpdate] is set on the label.
-|`copy_values`   |optional|
-List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|optional|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4319,6 +4517,57 @@
 |`size_of_packed_objects`  |Size of packed objects in bytes.
 |======================================
 
+[[submit-requirement-info]]
+=== SubmitRequirementInfo
+The `SubmitRequirementInfo` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`||
+Whether this submit requirement can be overridden in child projects.
+|===========================
+
+[[submit-requirement-input]]
+=== SubmitRequirementInput
+The `SubmitRequirementInput` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`|optional|
+Whether this submit requirement can be overridden in child projects. Default is
+`false`.
+|===========================
+
 [[submit-type-info]]
 === SubmitTypeInfo
 Information about the link:config-project-config.html#submit-type[default submit
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 469bee5..348af76 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -76,6 +76,16 @@
 headers. If the named resource already exists the server will respond
 with HTTP 412 Precondition Failed.
 
+[[backwards-compatibility]]
+=== Backwards Compatibility
+
+The REST API is regularly extended (e.g. addition of new REST endpoints or new fields in existing
+JSON entities). Callers of the REST API must be able to deal with this (e.g. ignore unknown fields
+in the REST responses). Incompatible changes (e.g. removal of REST endpoints, altering/removal of
+existing fields in JSON entities) are avoided if possible, but can happen in rare cases. If they
+happen, they are announced in the link:https://www.gerritcodereview.com/releases-readme.html[release
+notes].
+
 [[output]]
 === Output Format
 JSON responses are encoded using UTF-8 and use content type
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 1f67fc7..5e5d3f8 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -2,8 +2,6 @@
 
 Report a bug or send feedback using
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
-You can also report a bug through the bug icon in the user hovercard and in the
-reply dialog.
 
 [[whose-turn]]
 == Whose turn is it?
@@ -49,6 +47,9 @@
   an unresolved comment.
 * If another user removed a user's vote, the user with the deleted vote will be
   added to the attention set.
+* If a vote becomes outdated by uploading a new patch set (vote is not sticky),
+  the user whose vote has been removed is added to the attention set, as they
+  need to re-review the change and vote newly.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 * The rules for service accounts are different, see link:#bots[Bots].
 * Users are not added by automatic rules when the change is work in progress.
@@ -133,23 +134,26 @@
 Gerrit-Attention: Marian Harbach <mharbach@google.com>
 ----
 
-=== Assignee
+=== Browser notifications
 
-While the "Assignee" feature can still be used together with the attention set,
-we do not recommend doing so. Using both features is likely confusing. The
-distinct feature of the "Assignee" compared to the attention set is that only
-one user can be the assignee at the same time. So the assignee can be used to
-single out one person or escalate, if there are multiple reviewers. Since
-*every* reviewer in the attention set is expected to take action, singling out
-is not likely to be important and also still achievable with the attention set.
-Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
-recommend to only use one of them.
+You'll automatically get notifications when you are in the attention set. You
+must enable desktop notifications on your browser to see them.
 
-If you don't expect action from reviewers, then consider adding them to CC
-instead.
+image::images/browser-notification-example.png["browser notification example", align="center"]
 
-The "Assignee" feature can be turned on/off with the
-link:config-gerrit.html#change.enableAssignee[enableAssignee] config option.
+You can turn off automatic notifications in user preferences. They are enabled
+by default.
+
+image::images/browser-notification-preference.png["user preference for browser notifications", align="center"]
+
+The notifications work only when Gerrit is open in one of the browser tabs.
+The latency to get the notification is up to 5 minutes.
+
+If you are not getting notifications:
+ - Check your user preferences - Allow browser notification setting
+ - Make sure notifications are turned on for the Gerrit site in the browser
+ - Make sure browser notifications are turned on in your operating system
+ - Your host can have browser notifications disabled for some user groups
 
 === Bold Changes / Mark Reviewed
 
diff --git a/Documentation/user-dashboards.txt b/Documentation/user-dashboards.txt
index e64d625..364b0d9 100644
--- a/Documentation/user-dashboards.txt
+++ b/Documentation/user-dashboards.txt
@@ -12,7 +12,7 @@
 
 Dashboards are available via URLs like:
 ----
-  /#/dashboard/?title=Custom+View&To+Review=reviewer:john.doe@example.com&Pending+In+myproject=project:myproject+is:open
+  /dashboard/?title=Custom+View&To+Review=reviewer:john.doe@example.com&Pending+In+myproject=project:myproject+is:open
 ----
 This opens a view showing the title "Custom View" with two sections,
 "To Review" and "Pending in myproject":
@@ -51,7 +51,7 @@
 to changes for the current user:
 
 ----
-  /#/dashboard/?title=Mine&foreach=owner:self&My+Pending=is:open&My+Merged=is:merged
+  /dashboard/?title=Mine&foreach=owner:self&My+Pending=is:open&My+Merged=is:merged
 ----
 
 
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 20ad07c..24c35f0 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -18,8 +18,8 @@
 [[user]]
 == User Level Settings
 
-Individual users can configure email subscriptions by editing
-watched projects through Settings > Watched Projects with the web UI.
+Individual users can configure email subscriptions by editing their
+notifications in the Web UI under `Settings` > `Notifications`.
 
 Specific projects may be watched, or the special project
 `All-Projects` can be watched to watch all projects that
diff --git a/Documentation/user-privacy.txt b/Documentation/user-privacy.txt
index d61ee76..afedb7e 100644
--- a/Documentation/user-privacy.txt
+++ b/Documentation/user-privacy.txt
@@ -8,7 +8,7 @@
 |===
 | Note: Gerrit has extensive support for link:config-plugins.html[plugins]
   which extend Gerrits functionality, and these plugins could access, export, or
-  maniuplate user data. This document only focuses on the behavior of Gerrit
+  manipulate user data. This document only focuses on the behavior of Gerrit
   core and its link:dev-core-plugins.html[core plugins].
 |===
 
@@ -98,11 +98,6 @@
 * Remove a user's e-mail from all existing commits
 * Remove a user's username
 
-There is also a known
-link:https://bugs.chromium.org/p/gerrit/issues/detail?id=14185[bug] where a
-user's username is stored in metadata for link:user-attention-set.html[Attention
-Set].
-
 
 ## Open Source Software Limitations
 
@@ -110,4 +105,4 @@
 required by applicable law or agreed to in writing, software distributed under
 the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
 OF ANY KIND, either express or implied. See the License for the specific
-language governing permissions and limitations under the License.
\ No newline at end of file
+language governing permissions and limitations under the License.
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 6f5f7297..73668d7 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -1,21 +1,15 @@
 :linkattrs:
-= Review UI
+= Review UI Overview
 
 Reviewing changes is an important task and the Gerrit Web UI provides
 many functionalities to make the review process comfortable and
 efficient.
 
-The UI has three different main views,
-
-** The dashboard, which shows all changes that are relevant to you
-** The change screen, which shows the change with all its metadata
-** The diff view, which shows changes to a single file
-
 [[change-screen]]
 == Change Screen
 
-The change screen shows the details of a single change and provides
-various actions on it.
+The change screen is the main view for a change. It shows the details of a
+single change and allows various actions on it.
 
 image::images/user-review-ui-change-screen.png[width=800, link="images/user-review-ui-change-screen.png"]
 
@@ -28,44 +22,81 @@
 
 Top left, you find the status of the change, and a permalink.
 
-image::images/user-review-ui-change-screen-topleft.png[width=400, link="images/user-review-ui-change-screen-topleft.png"]
+image::images/user-review-ui-change-screen-topleft.png[width=600, link="images/user-review-ui-change-screen-topleft.png"]
 
 [[change-status]]
 The change status shows the state of the change:
 
-- [[active]]`Active`:
+- `Active`:
 +
 The change is under active review.
 
-- [[merge-conflict]]`Merge Conflict`:
+- `Merge Conflict`:
 +
-The change can't be merged due to conflicts.
+The change can't be merged into the destination branch due to conflicts.
 
-- [[ready-to-submit]]`Ready to Submit`:
+- `Ready to Submit`:
 +
-The change has all necessary approvals and may be submitted.
+The change has all necessary approvals and fulfils all other submit
+requirements. It can be submitted.
 
-- [[merged]]`Merged`:
+- `Merged`:
 +
 The change was successfully merged into the destination branch.
 
-- [[abandoned]]`Abandoned`:
+- `Abandoned`:
 +
-The change was abandoned.
+The change was abandoned. It is not intended to be updated, reviewed or
+submitted anymore.
+
+- `Private`:
++
+The change is marked as link:intro-user.html#private-changes[Private]. And has
+reduced visibility.
+
+- `Revert Created|Revert Submitted`:
++
+The change has a corresponding revert change. Revert changes can be created
+through UI (see <<actions, Actions section>>).
+
+- `WIP`:
++
+The change was marked as "Work in Progress". For example to indicate to
+reviewers that they shouldn't review the change yet.
 
 [[star]]
 === Star Change
 
-Clicking the star icon marks the change as a favorite: it turns on
+Clicking the star icon bookmarks the change: it turns on
 email notifications for this change, and the change is added to the
 list under `Your` > `Starred Changes`. They can be queried by the
 link:user-search.html#is[is:starred] search operator.
 
+[[quick-links]]
+=== Links Menu
+
+Links menu contains various change related strings for quick copying. Such as:
+Change Number, URL, Title+Url, etc. The lines in this menu can also be accessed
+via shortcuts for convenience.
+
+image::images/user-review-ui-copy-links.png[width=600, link="images/user-review-ui-copy-links.png"]
+
 [[change-info]]
 === Change metadata
 
-The change metadata block contains detailed information about the change
-and offers actions on the change.
+The change metadata block contains detailed information about the change.
+
+image::images/user-review-ui-change-metadata.png[width=600, link="images/user-review-ui-change-metadata.png"]
+
+- [[owner]]Owner/Uploader/Author/Committer
++
+Owner is the person who created the change
++
+Uploader is the person who uploaded the latest patchset (the patchset that will
+be merged if the change is submitted)
++
+Author/Committer are concepts from Git and are retrieved from the commit when
+it's sent for review.
 
 - [[reviewers]]Reviewers:
 +
@@ -74,16 +105,36 @@
 For each reviewer there is a tooltip that shows on which labels the
 reviewer is allowed to vote.
 +
-New reviewers can be added by clicking on the pencil icon. Typing
-into the pop-up text field activates auto completion of user and group
-names.
+New reviewers can be added through reply dialog that is opened by clicking on
+the pencil icon or on "Reply" button. Typing into the reviewer text field
+activates auto completion of user and group names.
 +
+
+- [[cc-list]]CC:
++
+Accounts in CC receive notifications for the updates on the change, but don't
+need to vote/review. If the CC'ed user votes they are moved to reviewers.
++
+
+- [[attention-set]]Attention set:
++
+Users in attention set are marked by "chevron" symbol (see screenshot above).
+The mark indicates that there are actions their attention is required on the
+change: Something updated/changed since last review, their vote is required,
+etc.
++
+Changes for which you are currently in attention set can be found using
+`attention:<User>` in search and show up in a separate category of personal
+dashboard.
++
+Clicking on the mark removes the user from attention set.
+
+
 [[remove-reviewer]]
-Reviewers can be removed from the change by clicking on the `x` icon
-in the reviewer's chip token. Removing a reviewer also removes the
-current votes of the reviewer. The removal of votes is recorded as a
-message on the change.
-+
+Reviewers can be removed from the change by selecting the appropriate option on
+the chip's hovercard. Removing a reviewer also removes current votes of the
+reviewer. The removal of votes is recorded in the change log.
+
 Removing reviewers is protected by permissions:
 
 ** Users can always remove themselves.
@@ -92,10 +143,7 @@
    Remove Reviewer] access right, the branch owner, the project owner
    and Gerrit administrators may remove anyone.
 
-+
-image::images/user-review-ui-change-screen-info-reviewers.png[width=600, link="images/user-review-ui-change-screen-reviewers.png"]
-
-- [[project-branch-topic]]Project / Branch / Topic:
+- [[repo-branch-topic]]Project (Repo) / Branch / Topic:
 +
 The name of the project for which the change was done is displayed as a
 link to the link:user-dashboards.html#project-default-dashboard[default
@@ -112,15 +160,55 @@
 access right. To be able to set a topic on a closed change, the
 `Edit Topic Name` must be assigned with the `force` flag.
 
+- [[parent]]Parent:
++
+Parent commit of the latest uploaded patchset. Or if the change has been merged
+the parent of the commit it was merged as into the destination branch.
+
+- [[merged-as]]Merged As:
++
+The SHA of the commit corresponding to the merged change on the destination
+branch.
+
+- [[revert-created-as]]Revert (Created|Submitted) As
++
+Points to the revert change, if one was created.
+
+- [[cherry-pick-of]]Cherry-pick of
++
+If the change was created as cherry-pick of some other change to a different
+branch, points to the original change.
+
 - [[submit-strategy]]Submit Strategy:
 +
 The link:project-setup.html#submit_type[submit strategy] that will be
 used to submit the change. The submit strategy is only displayed for
 open changes.
 
-- [[actions]]Actions:
+- [[hastags]]Hashtags:
 +
-Actions buttons are at the top, and in the overflow menu.
+Arbitrary string hashtags, that can be used to categorize changes and later use
+hashtags for search queries.
+
+[[submit-requirements]]
+=== Submit Requirements
+
+image::images/user-review-ui-submit-requirements.png[width=600, link="images/user-review-ui-copy-links.png"]
+
+Submit Requirements describe various conditions that must be fulfilled before
+the change can be submitted. Hovering over the requirement will show the
+description of the requirement, as well as additional information, such as:
+corresponding expression that is being evaluated, who can vote on the related
+labels etc.
+
+Approving votes are colored green; negative votes are colored red.
+
+For more detail on Submit Requirements see
+link:config-submit-requirements.html[Submit Requirement Configuration] page.
+
+[[actions]]
+=== Actions:
+Actions buttons are at the top right and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
 
@@ -220,13 +308,7 @@
 +
 image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
-- [[labels]]Labels & Votes:
-+
-Approving votes are colored green; negative votes are colored red.
-+
-image::images/user-review-ui-change-screen-change-info-labels.png[width=400, link="images/user-review-ui-change-screen-change-info-labels.png"]
-
-[[files]]
+[[files-tab]]
 === File List
 
 The file list shows the files that are modified in the currently viewed
@@ -251,17 +333,40 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+Every file is accompanied by a number of extra information, such as status
+(modified, added, deleted, etc.), number of changed lines, type (executable,
+link, plain), comments and others. Hovering over most icons and columns reveals
+additional information.
+
+Each file can be expanded to view the contents of the file and diff. For more
+information see <<diff-view, Diff View>> section.
+
+[[comments-tab]]
+=== Comments Tab
+
+Instead of the file list, a comments tab can be selected. Comments tab presents
+comments along with related file/diff snippets. It also offers some filtering
+opportunities at the top (ex. only unresolved, only comments from user X, etc.)
+
+image::images/user-review-ui-change-screen-comments-tab.png[width=800, link="images/user-review-ui-change-screen-comments-tab.png"]
+
+[[checks-tab]]
+=== Checks Tab
+Checks tab contains results of different "Check Runs" installed by plugins. For
+more information see link:pg-plugin-checks-api.html[Checks API] page.
 
 [[patch-sets]]
 === Patch Sets
 
-The change screen only presents one patch set at a time. Which patch
-set is currently viewed can be seen from the `Patch Sets` drop-down
-panel in the change header.
+The change screen only presents one pair of patch sets (`Patchset A` and
+`Patchset B`) at a time. `A` is always an earlier upload than `B` and serves as
+a base for diffing when viewing changes in the files. Which patch
+sets is currently viewed can be seen from the `Patch Sets` drop-down
+panel in the change header. If patchset 'A' is not selected a parent commit of
+patchset 'B' is used by default.
 
 image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
 
-
 [[download]]
 === Download
 
@@ -278,7 +383,8 @@
 
 Each command has a copy-to-clipboard icon that allows the command to be
 copied into the clipboard. This makes it easy to paste and execute the
-command on a Git command line.
+command on a Git command line. Additionally each line can copied to clipboard
+using number (1..9) of the appropriate line as a keyboard shortcut.
 
 If several download schemes are configured on the server (e.g. SSH and
 HTTP) there is a drop-down list to switch between the download schemes.
@@ -306,22 +412,20 @@
 
 image::images/user-review-ui-change-screen-included-in.png[width=800, link="images/user-review-ui-change-screen-included-in.png"]
 
-
-
 [[related-changes]]
 === Related Changes
 
 If there are changes that are related to the currently viewed change
 they are displayed in the third column of the change screen.
 
-There are several lists of related changes and a tab control is used to
-display each list of related changes in its own tab.
+There are several lists of related changes that are displayed in separate
+sectionsunder each other.
 
-The following tabs may be displayed:
+The following sections may be displayed:
 
-- [[related-changes-tab]]`Related Changes`:
+- [[related-changes-section]]`Related Changes`:
 +
-This tab page shows changes on which the current change depends
+This section shows changes on which the current change depends
 (ancestors) and open changes that depend on the current change
 (descendants). For merge commits it also shows the closed changes that
 will be merged into the destination branch by submitting the merge
@@ -341,10 +445,10 @@
 +
 ** [[not-current]]Not current:
 +
-The selected patch set of the change is outdated; it is not the current
-patch set of the change.
+The patch set of the related change which is related to the current change is
+outdated; it is not the current patch set of the change.
 +
-It means that the
+For ancestor it means that the
 currently viewed patch set depends on a outdated patch set of the
 ancestor change. This is because a new patch set for the ancestor
 change was uploaded in the meantime and as result the currently viewed
@@ -364,20 +468,24 @@
 note that following the link to an indirect descendant change may
 result in a completely different related changes listing.
 
-** [[closed-ancestor]]Closed ancestor:
+** [[merged-related-change]]Merged
 +
-Indicates a closed ancestor, e.g. the commit was directly pushed into
-the repository bypassing code review, or the ancestor change was
-reviewed and submitted on another branch. The latter may indicate that
-the user has accidentally pushed the commit to the wrong branch, e.g.
-the commit was done on `branch-a`, but was then pushed to
-`refs/for/branch-b`.
+The change has been  merged.
++
+If the relationship to submitted change falls under conditions described in
+<<not-current, Not current>> the status is orange. Such changes can appear as
+both ancestors and descendants of the change.
+
+** [[submittable-related-change]]Submittable
++
+All the submit requirements are fulfilled for the related change and it can be
+submitted when all of its ancestors are submitted.
 
 ** [[closed-ancestor-abandoned]]Abandoned:
 +
 Indicates an abandoned change.
 
-- [[conflicts-with]]`Conflicts With`:
+- [[conflicts-with]]`Merge Conflicts`:
 +
 This section shows changes that conflict with the current change.
 Non-mergeable changes are filtered out; only conflicting changes that
@@ -393,10 +501,9 @@
 currently viewed change, when clicking the submit button. It includes
 ancestors of the current patch set.
 +
-This may include changes and its ancestors with the same topic if
-`change.submitWholeTopic` is enabled. Only open changes with the
-same topic are included in the list.
-+
+If `change.submitWholeTopic` is enabled this section also includes changes with
+the same topic. The list recursively includes all changes that can be reached by
+ancestor and topic relationships. Only open changes are included in the result.
 
 - [[cherry-picks]]`Cherry-Picks`:
 +
@@ -411,12 +518,18 @@
 
 If there are no related changes for a tab, the tab is not displayed.
 
+- [[same-topic]]`Same Topic`:
++
+This section shows changes which are part of the same topic. If
+`change.submitWholeTopic` is enabled, then this section is omitted and changes
+are included as part of <<submitted-together, `Submitted Together`>>
+
 [[reply]]
 === Reply
 
 The `Reply...` button in the change header allows to reply to the
 currently viewed patch set; one can add a summary comment, publish
-inline draft comments, and vote on the labels.
+inline draft comments, vote on the labels and adjust attention set.
 
 image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
 
@@ -424,10 +537,8 @@
 
 [[summary-comment]]
 A text box allows to type a summary comment for the currently viewed
-patch set. Some basic markdown-like syntax is supported which renders
-indented lines preformatted, lines starting with "- " or "* " as list
-items, and lines starting with "> " as block quotes (also see replying to
-link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
+patch set. Markdown syntax is supported same as in other
+<<comments-markdown, Comments>>.
 
 [[vote]]
 If the current patch set is viewed, buttons are displayed for
@@ -439,7 +550,7 @@
 are links to navigate to the inline comments which can be used if a
 comment needs to be edited.
 
-The `Post` button publishes the comments and the votes.
+The `SEND` button publishes the comments and the votes.
 
 [[quick-approve]]
 If a user can approve a label that is still required, a quick approve
@@ -460,12 +571,12 @@
 
 image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
 
-[[history]]
-=== History
+[[change-log]]
+=== Change Log
 
 The history of the change can be seen in the lower part of the screen.
 
-The history contains messages for all kinds of change updates, e.g. a
+The log contains messages for all kinds of change updates, e.g. a
 message is added when a new patch set is uploaded or when a review was
 done.
 
@@ -491,12 +602,12 @@
 
 image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
 
-[[side-by-side]]
+[[diff-view]]
 == Side-by-Side Diff Screen
 
-The side-by-side diff screen shows a single patch; the old file version
-is displayed on the left side of the screen; the new file version is
-displayed on the right side of the screen.
+The side-by-side diff screen shows a single patch (or difference between two
+patchsets); the old file version is displayed on the left side of the screen;
+the new file version is displayed on the right side of the screen.
 
 This screen allows to review a patch and to comment on it.
 
@@ -557,6 +668,10 @@
 Code blocks with comments may overlap. This means it is possible to
 attach several comments to the same code.
 
+[[comments-markdown]]
+The comments support markdown. It follows the CommonMark spec, except inline
+images and direct HTML are not rendered and kept as plaintext.
+
 [[line-links]]
 The lines of the patch file are linkable: simply append
 '#<linenumber>' to the URL, or click on the line-number. This not only
@@ -565,15 +680,14 @@
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
 
-Quoting is supported, but only by manually copying & pasting the old
-comment that should be quoted and prefixing every line by "> ". Please
-note that for a correct rendering it is important to leave a blank line
-between a quoted block and the reply to it.
+Previous comment can be quoted using "Quote" button. A new draft would be open
+on the same comment thread with the text of the previoused comment quoted using
+markdown syntax.
 
 image::images/user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-inline-comments.png"]
 
 Comments are first saved as drafts, and you can revisit the drafts as
-you read through code review. Finally, they should be published by
+you read through code review. Finally, they will be published by
 clicking the "Reply".
 
 [[done]]
@@ -610,6 +724,21 @@
 make it visible to other users it must be published from the change
 screen by link:#reply[replying] to the change.
 
+[[suggest-fix]]
+=== Suggest fix (WIP)
+Comments can contain suggested fixes.
+
+Clicking "Suggest Fix" will insert a special code-block in the text of the
+comment. The contents of this code block will replace the lines the comment is
+attached to (what gets highlighted when hovering over comment).
+
+image::images/user-review-ui-suggest-fix.png[width=400, link="images/user-review-ui-suggest-fix.png"]
+
+The author of the change can then preview and apply the change. This will created
+a new patchset with changes applied.
+
+image::images/user-review-ui-apply-fix.png[width=800, link="images/user-review-ui-apply-fix.png"]
+
 [[file-level-comments]]
 === File Level Comments
 
diff --git a/Documentation/user-search-accounts.txt b/Documentation/user-search-accounts.txt
index 6bcd18e..d5318c9 100644
--- a/Documentation/user-search-accounts.txt
+++ b/Documentation/user-search-accounts.txt
@@ -26,7 +26,10 @@
 [[cansee]]
 cansee:'CHANGE'::
 +
-Matches accounts that can see the change 'CHANGE'.
+Matches accounts that can see the change 'CHANGE'. If the change is private,
+this operator will match with the owner/reviewers/ccs of the change if the
+caller is in owner/reviewers/ccs of the change. Otherwise, the request will fail
+with 404 `Bad Request` with "change not found" message.
 
 [[email]]
 email:'EMAIL'::
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 6e6b9d7..e12c27c 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -8,16 +8,18 @@
 query, execute it, and present the results.
 
 [options="header"]
-|=================================================
-|Description          | Default Query
-|All > Open           | status:open '(or is:open)'
-|All > Merged         | status:merged
-|All > Abandoned      | status:abandoned
-|My > Watched Changes | is:watched is:open
-|My > Starred Changes | is:starred
-|My > Draft Comments  | has:draft
-|Open changes in Foo  | status:open project:Foo
-|=================================================
+|=======================================================
+|Description                | Default Query
+|Changes > Open             | status:open '(or is:open)'
+|Changes > Merged           | status:merged
+|Changes > Abandoned        | status:abandoned
+|Your > Watched Changes     | is:watched is:open
+|Your > Starred Changes     | is:starred
+|Your > Draft Comments      | has:draft
+|Your > Edits               | has:edit
+|Your > All Visible Changes | is:visible
+|Open changes in Foo        | status:open project:Foo
+|=======================================================
 
 
 == Basic Change Search
@@ -41,6 +43,17 @@
 For more predictable results, use explicit search operators as described
 in the following section.
 
+[IMPORTANT]
+--
+The change search API is backed by a secondary index and might sometimes return
+stale results if the re-indexing operation failed for a change update.
+
+Please also note that changes are not re-indexed if the project configuration
+is updated with newly added or modified
+link:config-submit-requirements.html[submit requirements].
+--
+
+
 [[search-operators]]
 == Search Operators
 
@@ -49,6 +62,10 @@
 returned results. Search can also be performed by typing only a
 text with no operator, which will match against a variety of fields.
 
+Characters in operator values can be escaped by enclosing the value with
+double quotes and escaping characters with a backslash. For example
+`message:"This \"is\" fixing a bug"`.
+
 [[age]]
 age:'AGE'::
 +
@@ -264,6 +281,11 @@
 often combined with 'branch:' and 'project:' operators to select
 all related changes in a series.
 
+[[prefixtopic]]
+prefixtopic:'TOPIC'::
++
+Changes whose designated topic start with 'TOPIC'.
+
 [[inhashtag]]
 inhashtag:'HASHTAG'::
 +
@@ -280,6 +302,12 @@
 Changes whose link:intro-user.html#hashtags[hashtag] matches 'HASHTAG'.
 The match is case-insensitive.
 
+[[prefixhashtag]]
+prefixhashtag:'HASHTAG'::
++
+Changes whose link:intro-user.html#hashtags[hashtag] start with 'HASHTAG'.
+The match is case-insensitive.
+
 [[cherrypickof]]
 cherrypickof:'CHANGE[,PATCHSET]'::
 +
@@ -323,6 +351,24 @@
 message:'MESSAGE'::
 +
 Changes that match 'MESSAGE' arbitrary string in the commit message body.
+By default full text matching is used, but regular expressions can be
+enabled by starting with `^`.
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns. Note, that searching with
+regular expressions is limited to the first 32766 bytes of the
+commit message due to limitations in Lucene.
+
+[[subject]]
+subject:'SUBJECT'::
++
+Changes that have a commit message where the first line (aka the subject)
+matches 'SUBJECT'. The matching is done by full text search over the subject.
+
+[[prefixsubject]]
+prefixsubject:'PREFIX'::
++
+Changes that have a commit message where the first line (aka the subject)
+has the prefix 'PREFIX'.
 
 [[comment]]
 comment:'TEXT'::
@@ -344,7 +390,7 @@
 files, use `file:^.*\.java`.
 +
 The entire regular expression pattern, including the `^` character,
-should be double quoted. For example, to match all XML
+can be double quoted. For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
 +
@@ -352,8 +398,8 @@
 +
 *More examples:*
 
-* `-file:^path/.*` - changes that do not modify files from `path/`.
-* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+* `-path:^path/.*` - changes that do not modify files from `path/`.
+* `path:{^~(path/.*)}` - changes that modify files not from `path/` (but may
 contain files from `path/`).
 
 [[file]]
@@ -408,18 +454,11 @@
 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'::
+[[hasfooter-operator]]
+hasfooter:'FOOTERNAME'::
 +
-Matches any change that was starred by the current user with the label
-'LABEL'.
-+
-E.g. if changes that are not interesting are marked with an `ignore`
-star, they could be filtered out by '-star:ignore'.
-+
-'star:star' is the same as 'has:star' and 'is:starred'.
-
-Only "ignore" and "star" are supported labels.
+Matches any change that has a commit message with a footer where the footer
+name is equal to 'FOOTERNAME'.The matching is done case-sensitive.
 
 [[has]]
 has:draft::
@@ -429,8 +468,8 @@
 [[has-star]]
 has:star::
 +
-Same as 'is:starred' and 'star:star', true if the change has been
-starred by the current user with the default label.
+Same as 'is:starred', true if the change has been starred by the current user
+with the default label.
 
 has:edit::
 +
@@ -507,6 +546,7 @@
 +
 Same as <<status,status:'STATE'>>.
 
+[[is-submittable]]
 is:submittable::
 +
 True if the change is submittable according to the submit rules for
@@ -518,8 +558,6 @@
 use the
 link:rest-api-changes.html#get-revision-actions[Get Revision Actions]
 API.
-+
-Equivalent to <<submittable,submittable:ok>>.
 
 [[mergeable]]
 is:mergeable::
@@ -531,14 +569,9 @@
 not find any abandoned but mergeable changes.
 +
 This operator only works if Gerrit indexes 'mergeable'. See
-link:config-gerrit.html#index.change.indexMergeable[indexMergeable]
+link:config-gerrit.html#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior]
 for details.
 
-[[ignored]]
-is:ignored::
-+
-True if the change is ignored. Same as `star:ignore`.
-
 [[private]]
 is:private::
 +
@@ -610,7 +643,7 @@
 +
 Changes containing a top-level or inline comment by 'USER'. The special
 case of `commentby:self` will find changes where the caller has
-commented.
+commented. Note that setting a vote is also considered as a comment.
 
 [[from]]
 from:'USER'::
@@ -639,16 +672,6 @@
 email address. The special case of `committer:self` will find changes committed
 by the caller.
 
-
-[[submittable]]
-submittable:'SUBMIT_STATUS'::
-+
-Changes having the given submit record status after applying submit
-rules. Valid statuses are in the `status` field of
-link:rest-api-changes.html#submit-record[SubmitRecord]. This operator
-only applies to the top-level status; individual label statuses can be
-searched link:#labels[by label].
-
 [[rule]]
 rule:'SUBMIT_RULE_NAME'::
 +
@@ -656,8 +679,7 @@
 FORCED}. This means that the submit rule has passed and is not blocking the
 change submission. 'SUBMIT_RULE_NAME' should be in the form of
 '$plugin_name~$rule_name'. For gerrit core rules, 'SUBMIT_RULE_NAME' should
-be in the form of '$rule_name' (example: `DefaultSubmitRule`), or
-'gerrit~$rule_name' (example: `gerrit~DefaultSubmitRule`).
+be in the form of 'gerrit~$rule_name' (example: `gerrit~DefaultSubmitRule`).
 
 rule:'SUBMIT_RULE_NAME'='STATUS'::
 +
@@ -699,17 +721,20 @@
 `-is:starred` is the exact opposite of `is:starred` and will
 therefore return changes that are *not* starred by the current user.
 
-The operator `NOT` (in all caps) is a synonym.
+The operator `NOT` (in all caps) or `not` (all lower case) is a
+synonym.
 
 === AND
-The boolean operator `AND` (in all caps) can be used to join two
-other operators together.  This results in a restriction of the
-results, returning only changes that match both operators.
+The boolean operator `AND` (in all caps) or `and` (all lower case)
+can be used to join two other operators together.  This results in
+a restriction of the results, returning only changes that match both
+operators.
 
 === OR
-The boolean operator `OR` (in all caps) can be used to find changes
-that match either operator.  This increases the number of results
-that are returned, as more changes are considered.
+The boolean operator `OR` (in all caps) or `or` (all lower case)
+can be used to find changes that match either operator. This
+increases the number of results that are returned, as more changes
+are considered.
 
 
 [[labels]]
@@ -793,12 +818,25 @@
 Note that a query like `label:Code-Review=+1,count<x` will not match with
 changes having zero +1 votes to this label.
 
-`label:Non-Author-Code-Review=need`::
+`label:Non-Author-Code-Review=need` (deprecated)::
 +
 Matches changes where the submit rules indicate that a label named
 `Non-Author-Code-Review` is needed. (See the
 link:prolog-cookbook.html#NonAuthorCodeReview[Prolog Cookbook] for how
 this label can be configured.)
++
+This operator is also compatible with
+link:config-submit-requirements.html[submit requirement] results. A submit
+requirement name could be used instead of the label name. The submit record
+statuses are mapped to submit requirement result statuses as follows:
++
+  * {`need`, `reject`} -> {`UNSATISFED`}
+  * {`ok`, `may`} -> {`SATISFIED`, `OVERRIDDEN`}
++
+For example, a query like `label:Code-Review=ok` will also match changes
+having a submit requirement with a result that is either `SATISFIED` or
+`OVERRIDDEN`. Users are encouraged not to rely on this operator since submit
+records are deprecated.
 
 `label:Code-Review=+2,aname`::
 `label:Code-Review=ok,aname`::
@@ -836,7 +874,7 @@
 +
 Matches changes that are ready to be submitted according to one common
 label configuration. (For a more general check, use
-link:#submittable[submittable:ok].)
+link:#is-submittable[is:submittable].)
 
 `is:open (label:Verified-1 OR label:Code-Review-2)`::
 `is:open (label:Verified=reject OR label:Code-Review=reject)`::
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 2bfc62d..8c51207 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -340,7 +340,7 @@
 To avoid confusion in parsing the git ref, at least the following characters
 must be percent-encoded: " %^@.~-+_:/!". Note that some of the reserved
 characters (like tilde) are not escaped in the standard URL encoding rules,
-so a language-provided function (e.g. encodeURIComponent(), in javascript)
+so a language-provided function (e.g. encodeURIComponent(), in JavaScript)
 might not suffice. To be safest, you might consider percent-encoding all
 non-alphanumeric characters (and all multibyte UTF-8 code points).
 
diff --git a/README.md b/README.md
index 8a4379b..c8f0b70 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
-[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/)
+[![Build Status](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/badge/icon)](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/)
 ![Maven Central](https://img.shields.io/maven-central/v/com.google.gerrit/gerrit-war)
 
 ## Objective
@@ -60,7 +60,7 @@
 
 On Debian/Ubuntu run:
 
-        apt-get update & apt-get install gerrit=<version>-<release>
+        apt-get update && apt-get install gerrit=<version>-<release>
 
 _NOTE: release is a counter that starts with 1 and indicates the number of packages that have
 been released with the same version of the software._
diff --git a/WORKSPACE b/WORKSPACE
index 93cae7d..047da6a 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,62 +1,61 @@
-# npm packages are split into different node_modules directories based on their usage.
-# 1. /node_modules (referenced as @npm) - contains packages to run tests, check code, etc...
-#    It is expected that @npm is used ONLY to run tools. No packages from @npm are used by
-#    other code in gerrit.
-# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder contains self-written tools
-#    which are run for building and/or testing. The @tools_npm directory contains all the packages needed to
-#    run this tools.
-# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which are necessary to run polygerrit
-#    and to bundle it. Only code from these packages can be included in the final bundle for polygerrit.
-#    @ui_npm folder must not have devDependencies. All dev dependencies must be placed in @ui_dev_npm
-# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit. The packages from these
-#    folder can be used for testing, but must not be included in the final bundle.
-# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit plugins.
-#    The packages here are expected to be used in plugins.
-# Note: separation between @ui_npm and @ui_dev_npm is necessary because with bazel we can't generate
-#    two managed directories from the same package.json. At the same time we want to avoid accidental
-#    usages of code from devDependencies in polygerrit bundle.
+# npm packages are split into different node_modules directories based on their
+# usage.
+# 1. @npm (node_modules) - contains packages to run tests, check code, etc...
+#    It is expected that @npm is used ONLY to run tools. No packages from @npm
+#    are used by other code in gerrit.
+# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder
+#    contains self-written tools which are run for building and/or testing. The
+#    @tools_npm directory contains all the packages needed to run this tools.
+# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which
+#    are necessary to run polygerrit and to bundle it. Only code from these
+#    packages can be included in the final bundle for polygerrit. @ui_npm folder
+#    must not have devDependencies. All devDependencies must be placed in
+#    @ui_dev_npm.
+# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit.
+#    The packages from these folder can be used for testing, but must not be
+#    included in the final bundle.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit
+#    plugins. The packages here are expected to be used in plugins.
+# Note: separation between @ui_npm and @ui_dev_npm is necessary because with
+#    rules_nodejs we can't generate two external repositories from the same
+#    package.json. At the same time we want to avoid accidental usages of code
+#    from devDependencies in polygerrit bundle.
 workspace(
     name = "gerrit",
-    managed_directories = {
-        "@npm": ["node_modules"],
-        "@ui_npm": ["polygerrit-ui/app/node_modules"],
-        "@ui_dev_npm": ["polygerrit-ui/node_modules"],
-        "@tools_npm": ["tools/node_tools/node_modules"],
-        "@plugins_npm": ["plugins/node_modules"],
-    },
 )
 
 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("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
-load("//tools:nongoogle.bzl", "TESTCONTAINERS_VERSION", "declare_nongoogle_deps")
+load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
+load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
 
 http_archive(
-    name = "bazel_toolchains",
-    sha256 = "1adf7a8e9901287c644dcf9ca08dd8d67a69df94bedbd57a841490a84dc1e9ed",
-    strip_prefix = "bazel-toolchains-5.0.0",
+    name = "platforms",
+    sha256 = "379113459b0feaf6bfbb584a91874c065078aa673222846ac765f86661c27407",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/v5.0.0.tar.gz",
-        "https://github.com/bazelbuild/bazel-toolchains/archive/v5.0.0.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
+        "https://github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
     ],
 )
 
-load("@bazel_toolchains//rules:rbe_repo.bzl", "rbe_autoconfig")
-
-# Creates a default toolchain config for RBE.
-rbe_autoconfig(
+http_archive(
     name = "rbe_jdk11",
-    java_home = "/usr/lib/jvm/11.29.3-ca-jdk11.0.2/reduced",
-    use_checked_in_confs = "Force",
+    sha256 = "dbcfd6f26589ef506b91fe03a12dc559ca9c84699e4cf6381150522287f0e6f6",
+    strip_prefix = "rbe_autoconfig-3.1.0",
+    urls = [
+        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v3.1.0.tar.gz",
+        "https://github.com/davido/rbe_autoconfig/archive/v3.1.0.tar.gz",
+    ],
 )
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
-    strip_prefix = "protobuf-3.14.0",
+    sha256 = "3bd7828aa5af4b13b99c191e8b1e884ebfa9ad371b0ce264605d347f135d2568",
+    strip_prefix = "protobuf-3.19.4",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.19.4.tar.gz",
     ],
 )
 
@@ -66,58 +65,43 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "10f534e1c80f795cffe1f2822becd4897754d18564612510c59b3c73544ae7c6",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.0/rules_nodejs-3.5.0.tar.gz"],
+    sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
 )
 
+load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
+
+build_bazel_rules_nodejs_dependencies()
+
+# This is required just because we have a dependency on @bazel/concatjs.
+# We don't actually use any of this web_testing stuff.
+# TODO: Remove this dependency.
 http_archive(
-    name = "rules_pkg",
-    sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d",
+    name = "io_bazel_rules_webtesting",
+    sha256 = "e9abb7658b6a129740c0b3ef6f5a2370864e102a5ba5ffca2cea565829ed825a",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
-        "https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
+        "https://github.com/bazelbuild/rules_webtesting/releases/download/0.3.5/rules_webtesting.tar.gz",
     ],
 )
 
-load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+load("@io_bazel_rules_webtesting//web:repositories.bzl", "web_test_repositories")
 
-rules_pkg_dependencies()
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+web_test_repositories()
 
-# Golang support for PolyGerrit local dev server.
-http_archive(
-    name = "io_bazel_rules_go",
-    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
-    ],
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+load("@io_bazel_rules_webtesting//web/versioned:browsers-0.3.3.bzl", "browser_repositories")
+
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+browser_repositories(
+    chromium = True,
+    firefox = True,
 )
 
-load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
+register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
 
-go_rules_dependencies()
-
-go_register_toolchains()
-
-http_archive(
-    name = "bazel_gazelle",
-    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.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",
-)
+register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
 
 # JGit external repository consumed from git submodule
 local_repository(
@@ -125,92 +109,9 @@
     path = "modules/jgit",
 )
 
-ANTLR_VERS = "3.5.2"
+java_dependencies()
 
-maven_jar(
-    name = "java-runtime",
-    artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
-    sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
-)
-
-maven_jar(
-    name = "stringtemplate",
-    artifact = "org.antlr:stringtemplate:4.0.2",
-    sha1 = "e28e09e2d44d60506a7bcb004d6c23ff35c6ac08",
-)
-
-maven_jar(
-    name = "org-antlr",
-    artifact = "org.antlr:antlr:" + ANTLR_VERS,
-    sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764",
-)
-
-maven_jar(
-    name = "antlr27",
-    artifact = "antlr:antlr:2.7.7",
-    attach_source = False,
-    sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
-)
-
-maven_jar(
-    name = "aopalliance",
-    artifact = "aopalliance:aopalliance:1.0",
-    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
-)
-
-maven_jar(
-    name = "javax_inject",
-    artifact = "javax.inject:javax.inject:1",
-    sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
-)
-
-maven_jar(
-    name = "servlet-api",
-    artifact = "org.apache.tomcat:tomcat-servlet-api:8.5.23",
-    sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
-)
-
-# JGit's transitive dependencies
-maven_jar(
-    name = "hamcrest-library",
-    artifact = "org.hamcrest:hamcrest-library:1.3",
-    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
-)
-
-maven_jar(
-    name = "jzlib",
-    artifact = "com.jcraft:jzlib:1.1.1",
-    sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
-)
-
-maven_jar(
-    name = "javaewah",
-    artifact = "com.googlecode.javaewah:JavaEWAH:1.1.6",
-    attach_source = False,
-    sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
-)
-
-maven_jar(
-    name = "error-prone-annotations",
-    artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
-    sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
-)
-
-maven_jar(
-    name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.5",
-    sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
-)
-
-CAFFEINE_VERS = "2.8.5"
-
-maven_jar(
-    name = "caffeine",
-    artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
-    sha1 = "f0eafef6e1529a44e36549cd9d1fc06d3a57f384",
-)
-
-CAFFEINE_GUAVA_SHA256 = "a7ce6d29c40bccd688815a6734070c55b20cd326351a06886a6144005aa32299"
+CAFFEINE_GUAVA_SHA256 = "6e48965614557ba4d3c55a197e20c38f23a20032ef8aace37e95ed64d2ebc9a6"
 
 # TODO(davido): Rename guava.jar to caffeine-guava.jar on fetch to prevent potential
 # naming collision between caffeine guava adapter and guava library itself.
@@ -230,701 +131,22 @@
     ],
 )
 
-maven_jar(
-    name = "guava-failureaccess",
-    artifact = "com.google.guava:failureaccess:1.0.1",
-    sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
-)
-
-maven_jar(
-    name = "jsch",
-    artifact = "com.jcraft:jsch:0.1.54",
-    sha1 = "da3584329a263616e277e15462b387addd1b208d",
-)
-
-maven_jar(
-    name = "juniversalchardet",
-    artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
-    sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
-)
-
-SLF4J_VERS = "1.7.26"
-
-maven_jar(
-    name = "log-api",
-    artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
-    sha1 = "77100a62c2e6f04b53977b9f541044d7d722693d",
-)
-
-maven_jar(
-    name = "log-ext",
-    artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
-    sha1 = "31cdf122e000322e9efcb38913e9ab07825b17ef",
-)
-
-maven_jar(
-    name = "impl-log4j",
-    artifact = "org.slf4j:slf4j-log4j12:" + SLF4J_VERS,
-    sha1 = "12f5c685b71c3027fd28bcf90528ec4ec74bf818",
-)
-
-maven_jar(
-    name = "jcl-over-slf4j",
-    artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
-    sha1 = "33fbc2d93de829fa5e263c5ce97f5eab8f57d53e",
-)
-
-maven_jar(
-    name = "log4j",
-    artifact = "log4j:log4j:1.2.17",
-    sha1 = "5af35056b4d257e4b64b9e8069c0746e8b08629f",
-)
-
-maven_jar(
-    name = "json-smart",
-    artifact = "net.minidev:json-smart:1.1.1",
-    sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
-)
-
-maven_jar(
-    name = "args4j",
-    artifact = "args4j:args4j:2.33",
-    sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
-)
-
-maven_jar(
-    name = "commons-codec",
-    artifact = "commons-codec:commons-codec:1.10",
-    sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
-)
-
-# When upgrading commons-compress, also upgrade tukaani-xz
-maven_jar(
-    name = "commons-compress",
-    artifact = "org.apache.commons:commons-compress:1.18",
-    sha1 = "1191f9f2bc0c47a8cce69193feb1ff0a8bcb37d5",
-)
-
-maven_jar(
-    name = "commons-lang",
-    artifact = "commons-lang:commons-lang:2.6",
-    sha1 = "0ce1edb914c94ebc388f086c6827e8bdeec71ac2",
-)
-
-maven_jar(
-    name = "commons-lang3",
-    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(
-    name = "commons-dbcp",
-    artifact = "commons-dbcp:commons-dbcp:1.4",
-    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",
-    sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b",
-)
-
-maven_jar(
-    name = "commons-net",
-    artifact = "commons-net:commons-net:3.6",
-    sha1 = "b71de00508dcb078d2b24b5fa7e538636de9b3da",
-)
-
-maven_jar(
-    name = "commons-validator",
-    artifact = "commons-validator:commons-validator:1.6",
-    sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
-)
-
-maven_jar(
-    name = "automaton",
-    artifact = "dk.brics:automaton:1.12-1",
-    sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
-)
-
-COMMONMARK_VERS = "0.10.0"
-
-# commonmark must match the version used in Gitiles
-maven_jar(
-    name = "commonmark",
-    artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
-    sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
-)
-
-maven_jar(
-    name = "cm-autolink",
-    artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
-    sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
-)
-
-maven_jar(
-    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.50.42"
-
-maven_jar(
-    name = "flexmark",
-    artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-    sha1 = "ed537d7bc31883b008cc17d243a691c7efd12a72",
-)
-
-maven_jar(
-    name = "flexmark-ext-abbreviation",
-    artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-    sha1 = "dc27c3e7abbc8d2cfb154f41c68645c365bb9d22",
-)
-
-maven_jar(
-    name = "flexmark-ext-anchorlink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-    sha1 = "6a8edb0165f695c9c19b7143a7fbd78c25c3b99c",
-)
-
-maven_jar(
-    name = "flexmark-ext-autolink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-    sha1 = "5da7a4d009ea08ef2d8714cc73e54a992c6d2d9a",
-)
-
-maven_jar(
-    name = "flexmark-ext-definition",
-    artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-    sha1 = "862d17812654624ed81ce8fc89c5ef819ff45f87",
-)
-
-maven_jar(
-    name = "flexmark-ext-emoji",
-    artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-    sha1 = "f0d7db64cb546798742b1ffc6db316a33f6acd76",
-)
-
-maven_jar(
-    name = "flexmark-ext-escaped-character",
-    artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-    sha1 = "6fd9ab77619df417df949721cb29c45914b326f8",
-)
-
-maven_jar(
-    name = "flexmark-ext-footnotes",
-    artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-    sha1 = "e36bd69e43147cc6e19c3f55e4b27c0fc5a3d88c",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-issues",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-    sha1 = "5c825dd4e4fa4f7ccbe30dc92d7e35cdcb8a8c24",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-strikethrough",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-    sha1 = "3256735fd77e7228bf40f7888b4d3dc56787add4",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-tables",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-    sha1 = "62f0efcfb974756940ebe749fd4eb01323babc29",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-tasklist",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-    sha1 = "76d4971ad9ce02f0e70351ab6bd06ad8e405e40d",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-users",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-    sha1 = "7b0fc7e42e4da508da167fcf8e1cbf9ba7e21147",
-)
-
-maven_jar(
-    name = "flexmark-ext-ins",
-    artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-    sha1 = "9e51809867b9c4db0fb1c29599b4574e3d2a78e9",
-)
-
-maven_jar(
-    name = "flexmark-ext-jekyll-front-matter",
-    artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-    sha1 = "44eb6dbb33b3831d3b40af938ddcd99c9c16a654",
-)
-
-maven_jar(
-    name = "flexmark-ext-superscript",
-    artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-    sha1 = "35815b8cb91000344d1fe5df21cacde8553d2994",
-)
-
-maven_jar(
-    name = "flexmark-ext-tables",
-    artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-    sha1 = "f6768e98c7210b79d5e8bab76fff27eec6db51e6",
-)
-
-maven_jar(
-    name = "flexmark-ext-toc",
-    artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-    sha1 = "1968d038fc6c8156f244f5a7eecb34e7e2f33705",
-)
-
-maven_jar(
-    name = "flexmark-ext-typographic",
-    artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-    sha1 = "6549b9862b61c4434a855a733237103df9162849",
-)
-
-maven_jar(
-    name = "flexmark-ext-wikilink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-    sha1 = "e105b09dd35aab6e6f5c54dfe062ee59bd6f786a",
-)
-
-maven_jar(
-    name = "flexmark-ext-yaml-front-matter",
-    artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-    sha1 = "b2d3a1e7f3985841062e8d3203617e29c6c21b52",
-)
-
-maven_jar(
-    name = "flexmark-formatter",
-    artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-    sha1 = "a50c6cb10f6d623fc4354a572c583de1372d217f",
-)
-
-maven_jar(
-    name = "flexmark-html-parser",
-    artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-    sha1 = "46c075f30017e131c1ada8538f1d8eacf652b044",
-)
-
-maven_jar(
-    name = "flexmark-profile-pegdown",
-    artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-    sha1 = "d9aafd47629959cbeddd731f327ae090fc92b60f",
-)
-
-maven_jar(
-    name = "flexmark-util",
-    artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-    sha1 = "417a9821d5d80ddacbfecadc6843ae7b259d5112",
-)
-
-# 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"
-
-maven_jar(
-    name = "greenmail",
-    artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS,
-    sha1 = "9ea96384ad2cb8118c22f493b529eb72c212691c",
-)
-
-MAIL_VERS = "1.6.0"
-
-maven_jar(
-    name = "mail",
-    artifact = "com.sun.mail:javax.mail:" + MAIL_VERS,
-    sha1 = "a055c648842c4954c1f7db7254f45d9ad565e278",
-)
-
-MIME4J_VERS = "0.8.1"
-
-maven_jar(
-    name = "mime4j-core",
-    artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
-    sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61",
-)
-
-maven_jar(
-    name = "mime4j-dom",
-    artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
-    sha1 = "f2d653c617004193f3350330d907f77b60c88c56",
-)
-
-maven_jar(
-    name = "jsoup",
-    artifact = "org.jsoup:jsoup:1.9.2",
-    sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
-)
-
-OW2_VERS = "9.0"
-
-maven_jar(
-    name = "ow2-asm",
-    artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "af582ff60bc567c42d931500c3fdc20e0141ddf9",
-)
-
-maven_jar(
-    name = "ow2-asm-analysis",
-    artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "4630afefbb43939c739445dde0af1a5729a0fb4e",
-)
-
-maven_jar(
-    name = "ow2-asm-commons",
-    artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "5a34a3a9ac44f362f35d1b27932380b0031a3334",
-)
-
-maven_jar(
-    name = "ow2-asm-tree",
-    artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "9df939f25c556b0c7efe00701d47e77a49837f24",
-)
-
-maven_jar(
-    name = "ow2-asm-util",
-    artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "7c059a94ab5eed3347bf954e27fab58e52968848",
-)
-
-AUTO_VALUE_VERSION = "1.7.4"
-
-maven_jar(
-    name = "auto-value",
-    artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
-)
-
-maven_jar(
-    name = "auto-value-annotations",
-    artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
-)
-
-AUTO_VALUE_GSON_VERSION = "1.3.0"
-
-maven_jar(
-    name = "auto-value-gson-runtime",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
-)
-
-maven_jar(
-    name = "auto-value-gson-extension",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
-)
-
-maven_jar(
-    name = "auto-value-gson-factory",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
-)
-
-maven_jar(
-    name = "javapoet",
-    artifact = "com.squareup:javapoet:1.13.0",
-    sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
-)
-
-maven_jar(
-    name = "autotransient",
-    artifact = "io.sweers.autotransient:autotransient:1.0.0",
-    sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
-)
-
 declare_nongoogle_deps()
 
-maven_jar(
-    name = "mime-util",
-    artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
-    attach_source = False,
-    sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
+load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
+
+node_repositories(
+    node_version = "17.9.1",
+    yarn_version = "1.22.19",
 )
 
-PROLOG_VERS = "1.4.4"
-
-PROLOG_REPO = GERRIT
-
-maven_jar(
-    name = "prolog-runtime",
-    artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "e9a364f4233481cce63239e8e68a6190c8f58acd",
-)
-
-maven_jar(
-    name = "prolog-compiler",
-    artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "570295026f6aa7b905e423d107cb2e081eecdc04",
-)
-
-maven_jar(
-    name = "prolog-io",
-    artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "1f25c4e27d22bdbc31481ee0c962a2a2853e4428",
-)
-
-maven_jar(
-    name = "cafeteria",
-    artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "0e6c2deeaf5054815a561cbd663566fd59b56c6c",
-)
-
-maven_jar(
-    name = "guava-retrying",
-    artifact = "com.github.rholder:guava-retrying:2.0.0",
-    sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4",
-)
-
-maven_jar(
-    name = "jsr305",
-    artifact = "com.google.code.findbugs:jsr305:3.0.1",
-    sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
-)
-
-GITILES_VERS = "0.4-1"
-
-GITILES_REPO = GERRIT
-
-maven_jar(
-    name = "blame-cache",
-    artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
-    attach_source = False,
-    repository = GITILES_REPO,
-    sha1 = "0df80c6b8822147e1f116fd7804b8a0de544f402",
-)
-
-maven_jar(
-    name = "gitiles-servlet",
-    artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
-    repository = GITILES_REPO,
-    sha1 = "60870897d22b840e65623fd024eabd9cc9706ebe",
-)
-
-# prettify must match the version used in Gitiles
-maven_jar(
-    name = "prettify",
-    artifact = "com.github.twalcari:java-prettify:1.2.2",
-    sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
-)
-
-maven_jar(
-    name = "html-types",
-    artifact = "com.google.common.html.types:types:1.0.8",
-    sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777",
-)
-
-maven_jar(
-    name = "icu4j",
-    artifact = "com.ibm.icu:icu4j:57.1",
-    sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
-)
-
-# 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 = "00df4b474e71be02c1349c3292d98886f888d1f7",
-)
-
-maven_jar(
-    name = "bcpg",
-    artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
-    sha1 = "422656435514ab8a28752b117d5d2646660a0ace",
-)
-
-maven_jar(
-    name = "bcpkix",
-    artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
-    sha1 = "89bb3aa5b98b48e584eee2a7401b7682a46779b4",
-)
-
-maven_jar(
-    name = "h2",
-    artifact = "com.h2database:h2:1.3.176",
-    sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
-)
-
-# Base the following org.apache.httpcomponents versions on what
-# elasticsearch-rest-client explicitly depends on, except for
-# commons-codec (non-http) which is not necessary yet. Note that
-# below httpcore version(s) differs from the HTTPCOMP_VERS range,
-# upstream: that specific dependency has no HTTPCOMP_VERS version
-# equivalent currently.
-HTTPCOMP_VERS = "4.5.2"
-
-maven_jar(
-    name = "fluent-hc",
-    artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
-    sha1 = "7bfdfa49de6d720ad3c8cedb6a5238eec564dfed",
-)
-
-maven_jar(
-    name = "httpclient",
-    artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS,
-    sha1 = "733db77aa8d9b2d68015189df76ab06304406e50",
-)
-
-maven_jar(
-    name = "httpcore",
-    artifact = "org.apache.httpcomponents:httpcore:4.4.4",
-    sha1 = "b31526a230871fbe285fbcbe2813f9c0839ae9b0",
-)
-
-# Test-only dependencies below.
-
-maven_jar(
-    name = "junit",
-    artifact = "junit:junit:4.12",
-    sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec",
-)
-
-maven_jar(
-    name = "hamcrest-core",
-    artifact = "org.hamcrest:hamcrest-core:1.3",
-    sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
-)
-
-maven_jar(
-    name = "diffutils",
-    artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
-    sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
-)
-
-JETTY_VERS = "9.4.36.v20210114"
-
-maven_jar(
-    name = "jetty-servlet",
-    artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "b189e52a5ee55ae172e4e99e29c5c314f5daf4b9",
-)
-
-maven_jar(
-    name = "jetty-security",
-    artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "42030d6ed7dfc0f75818cde0adcf738efc477574",
-)
-
-maven_jar(
-    name = "jetty-server",
-    artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "88a7d342974aadca658e7386e8d0fcc5c0788f41",
-)
-
-maven_jar(
-    name = "jetty-jmx",
-    artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "bb3847eabe085832aeaedd30e872b40931632e54",
-)
-
-maven_jar(
-    name = "jetty-http",
-    artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "1eee89a55e04ff94df0f85d95200fc48acb43d86",
-)
-
-maven_jar(
-    name = "jetty-io",
-    artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "84a8faf9031eb45a5a2ddb7681e22c483d81ab3a",
-)
-
-maven_jar(
-    name = "jetty-util",
-    artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "925257fbcca6b501a25252c7447dbedb021f7404",
-)
-
-maven_jar(
-    name = "jetty-util-ajax",
-    artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
-    sha1 = "2f478130c21787073facb64d7242e06f94980c60",
-    src_sha1 = "7153d7ca38878d971fd90992c303bb7719ba7a21",
-)
-
-maven_jar(
-    name = "asciidoctor",
-    artifact = "org.asciidoctor:asciidoctorj:1.5.7",
-    sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
-)
-
-maven_jar(
-    name = "javax-activation",
-    artifact = "javax.activation:activation:1.1.1",
-    sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
-)
-
-maven_jar(
-    name = "javax-annotation",
-    artifact = "javax.annotation:javax.annotation-api:1.3.2",
-    sha1 = "934c04d3cfef185a8008e7bf34331b79730a9d43",
-)
-
-maven_jar(
-    name = "mockito",
-    artifact = "org.mockito:mockito-core:3.3.3",
-    sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
-)
-
-BYTE_BUDDY_VERSION = "1.10.7"
-
-maven_jar(
-    name = "bytebuddy",
-    artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
-    sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
-)
-
-maven_jar(
-    name = "bytebuddy-agent",
-    artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
-    sha1 = "c472fad33f617228601172682aa64f8b78508045",
-)
-
-maven_jar(
-    name = "objenesis",
-    artifact = "org.objenesis:objenesis:3.0.1",
-    sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
-)
-
-load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
-
 yarn_install(
     name = "npm",
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:package.json",
     package_path = "",
+    symlink_node_modules = True,
     yarn_lock = "//:yarn.lock",
 )
 
@@ -941,51 +163,43 @@
         # explicitly added to package.json.
         "--ignore-optional",
     ],
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:polygerrit-ui/app/package.json",
     package_path = "polygerrit-ui/app",
+    symlink_node_modules = True,
     yarn_lock = "//:polygerrit-ui/app/yarn.lock",
 )
 
 yarn_install(
     name = "ui_dev_npm",
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:polygerrit-ui/package.json",
     package_path = "polygerrit-ui",
+    symlink_node_modules = True,
     yarn_lock = "//:polygerrit-ui/yarn.lock",
 )
 
 yarn_install(
     name = "tools_npm",
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:tools/node_tools/package.json",
     package_path = "tools/node_tools",
+    symlink_node_modules = True,
     yarn_lock = "//:tools/node_tools/yarn.lock",
 )
 
 yarn_install(
     name = "plugins_npm",
     args = ["--prod"],
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:plugins/package.json",
     package_path = "plugins",
+    symlink_node_modules = True,
     yarn_lock = "//:plugins/yarn.lock",
 )
 
 external_plugin_deps()
-
-# When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
-# and httpasyncclient as necessary in tools/nongoogle.bzl. Consider
-# also the other org.apache.httpcomponents dependencies in
-# WORKSPACE.
-maven_jar(
-    name = "elasticsearch-rest-client",
-    artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.1",
-    sha1 = "59feefe006a96a39f83b0dfb6780847e06c1d0a8",
-)
-
-maven_jar(
-    name = "testcontainers-elasticsearch",
-    artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "595e3a50f59cd3c1d281ca6c1bc4037e277a1353",
-)
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 549946a..23641e3 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -22,6 +22,7 @@
     srcs = [":query"],
     visibility = [
         "//java/com/google/gerrit/index:__subpackages__",
+        "//java/com/google/gerrit/server:__subpackages__",
         "//javatests/com/google/gerrit:__subpackages__",
         "//javatests/com/google/gerrit/index:__pkg__",
         "//plugins:__pkg__",
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index 1bf20aa..ea521f9 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -142,9 +142,9 @@
   | EXACT_PHRASE
   ;
 
-AND: 'AND' ;
-OR:  'OR'  ;
-NOT: 'NOT' ;
+AND: 'AND' | 'and';
+OR:  'OR' | 'or'  ;
+NOT: 'NOT' | 'not' ;
 
 COLON: ':' ;
 
@@ -152,18 +152,19 @@
   :  ( ' ' | '\r' | '\t' | '\n' ) { $channel=HIDDEN; }
   ;
 
+fragment LOWERCASE_AND_UNDERSCORE: ('a'..'z' | '_')+ ;
+
 FIELD_NAME
-  : ('a'..'z' | '_')+
+  : LOWERCASE_AND_UNDERSCORE ( '-' LOWERCASE_AND_UNDERSCORE )*
   ;
 
 EXACT_PHRASE
-  : '"' ( ~('"') )* '"' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
+@init { final StringBuilder buf = new StringBuilder(); }
+  : '"' ( ESCAPE[buf] | i = ~('\\'|'"') { buf.appendCodePoint(i); } )* '"' {
+      setText(buf.toString());
     }
-  | '{' ( ~('{'|'}') )* '}' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
+  | '{' ( ESCAPE[buf] | i = ~('\\'|'{'|'}') { buf.appendCodePoint(i); } )* '}' {
+      setText(buf.toString());
     }
   ;
 
@@ -197,3 +198,11 @@
      // | '~' permit
      )
   ;
+
+fragment ESCAPE[StringBuilder buf] :
+    '\\'
+    ( 't' { buf.append('\t'); }
+    | 'n' { buf.append('\n'); }
+    | 'r' { buf.append('\r'); }
+    | i = ~('t'|'n'|'r') { buf.appendCodePoint(i); }
+    );
diff --git a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java b/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
deleted file mode 100644
index 08a529c..0000000
--- a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
+++ /dev/null
@@ -1,69 +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.googlesource.gerrit.convertkey;
-
-import com.jcraft.jsch.HostKey;
-import com.jcraft.jsch.JSchException;
-import java.io.File;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.security.GeneralSecurityException;
-import java.security.KeyPair;
-import org.apache.sshd.common.util.Buffer;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-
-public class ConvertKey {
-  public static void main(String[] args)
-      throws GeneralSecurityException, JSchException, IOException {
-    SimpleGeneratorHostKeyProvider p;
-
-    if (args.length != 1) {
-      System.err.println("Error: requires path to the SSH host key");
-      return;
-    } else {
-      File file = new File(args[0]);
-      if (!file.exists() || !file.isFile() || !file.canRead()) {
-        System.err.println("Error: ssh key should exist and be readable");
-        return;
-      }
-    }
-
-    p = new SimpleGeneratorHostKeyProvider();
-    // Gerrit's SSH "simple" keys are always RSA.
-    p.setPath(args[0]);
-    p.setAlgorithm("RSA");
-    Iterable<KeyPair> keys = p.loadKeys(); // forces the key to generate.
-    for (KeyPair k : keys) {
-      System.out.println("Public Key (" + k.getPublic().getAlgorithm() + "):");
-      // From Gerrit's SshDaemon class; use JSch to get the public
-      // key/type
-      final Buffer buf = new Buffer();
-      buf.putRawPublicKey(k.getPublic());
-      final byte[] keyBin = buf.getCompactData();
-      HostKey pub = new HostKey("localhost", keyBin);
-      System.out.println(pub.getType() + " " + pub.getKey());
-      System.out.println("Private Key:");
-      // Use Bouncy Castle to write the private key back in PEM format
-      // (PKCS#1)
-      // http://stackoverflow.com/questions/25129822/export-rsa-public-key-to-pem-string-using-java
-      StringWriter privout = new StringWriter();
-      JcaPEMWriter privWriter = new JcaPEMWriter(privout);
-      privWriter.writeObject(k.getPrivate());
-      privWriter.close();
-      System.out.println(privout);
-    }
-  }
-}
diff --git a/contrib/find-duplicate-usernames.sh b/contrib/find-duplicate-usernames.sh
index b59e5be..7a5750f 100755
--- a/contrib/find-duplicate-usernames.sh
+++ b/contrib/find-duplicate-usernames.sh
@@ -29,6 +29,18 @@
   usage
 fi
 
+if [ -z "$(git ls-remote . refs/meta/external-ids)" ]; then
+  cat <<EOF
+Could not find 'refs/meta/external-ids' in the local repository.
+
+Please fetch it using:
+
+  git fetch "$(git remote)" refs/meta/external-ids:refs/meta/external-ids
+
+EOF
+  exit 1
+fi
+
 # 1. find lines with user name and subsequent line in external-ids notes branch
 #    example output of git grep -A1 "\[externalId \"username:" refs/meta/external-ids:
 #    refs/meta/external-ids:00/1d/abd037e437f71d42134e6ad532a06948a2ba:[externalId "username:johndoe"]
@@ -45,12 +57,12 @@
 # 7. flip columns
 # 8. uniq case-insensitive, only show duplicates, avoid comparing first field
 # 9. flip columns back
-git grep -A1 "\[externalId \"$1:" refs/meta/external-ids \
+git grep -A1 "\[externalId \"$1:" refs/meta/external-ids -- \
   | sed -E "/$1/,/accountId/!d" \
   | paste -d ' ' - - \
   | tr \"= : \
   | cut -d: --output-delimiter="" -f 5,8 \
   | sort -f \
-  | sed -E "s/(.*) (.*)/\2 \1/" \
+  | sed -E "s/(.*) ([0-9]+)/\2 \1/" \
   | uniq -Di -f1 \
-  | sed -E "s/(.*) (.*)/\2 \1/"
+  | sed -E "s/([0-9]+) (.*)/\2 \1/"
diff --git a/contrib/git-gc-preserve b/contrib/git-gc-preserve
new file mode 100755
index 0000000..a886721
--- /dev/null
+++ b/contrib/git-gc-preserve
@@ -0,0 +1,149 @@
+#!/bin/bash
+# Copyright (C) 2022 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+usage() { # exit code
+  cat <<-EOF
+NAME
+    git-gc-preserve - Run git gc and preserve old packs to avoid races for JGit
+
+SYNOPSIS
+    git gc-preserve
+
+DESCRIPTION
+    Runs git gc and can preserve old packs to avoid races with concurrently
+    executed commands in JGit.
+
+    This command uses custom git config options to configure if preserved packs
+    from the last run of git gc should be pruned and if packs should be preserved.
+
+    This is similar to the implementation in JGit [1] which is used by
+    JGit to avoid errors [2] in such situations.
+
+    The command prevents concurrent runs of the command on the same repository
+    by acquiring an exclusive file lock on the file
+      "\$repopath/gc-preserve.pid"
+    If it cannot acquire the lock it fails immediately with exit code 3.
+
+    Failure Exit Codes
+        1: General failure
+        2: Couldn't determine repository path. If the current working directory
+           is outside of the working tree of the git repository use git option
+           --git-dir to pass the root path of the repository.
+           E.g.
+              $ git --git-dir ~/git/foo gc-preserve
+        3: Another process already runs $0 on the same repository
+
+    [1] https://git.eclipse.org/r/c/jgit/jgit/+/87969
+    [2] https://git.eclipse.org/r/c/jgit/jgit/+/122288
+
+CONFIGURATION
+    "gc.prunepreserved": if set to "true" preserved packs from the last gc run
+      are pruned before current packs are preserved.
+
+    "gc.preserveoldpacks": if set to "true" current packs will be hard linked
+      to objects/pack/preserved before git gc is executed. JGit will
+      fallback to the preserved packs in this directory in case it comes
+      across missing objects which might be caused by a concurrent run of
+      git gc.
+EOF
+  exit "$1"
+}
+
+# acquire file lock, unlock when the script exits
+lock() { # repo
+  readonly LOCKFILE="$1/gc-preserve.pid"
+  test -f "$LOCKFILE" || touch "$LOCKFILE"
+  exec 9> "$LOCKFILE"
+  if flock -nx 9; then
+    echo -n "$$ $USERNAME@$HOSTNAME" >&9
+    trap unlock EXIT
+  else
+    echo "$0 is already running"
+    exit 3
+  fi
+}
+
+unlock() {
+  # only delete if the file descriptor 9 is open
+  if { : >&9 ; } &> /dev/null; then
+    rm -f "$LOCKFILE"
+  fi
+  # close the file handle to release file lock
+  exec 9>&-
+}
+
+# prune preserved packs if gc.prunepreserved == true
+prune_preserved() { # repo
+  configured=$(git --git-dir="$1" config --get gc.prunepreserved)
+  if [ "$configured" != "true" ]; then
+    return 0
+  fi
+  local preserved=$1/objects/pack/preserved
+  if [ -d "$preserved" ]; then
+    printf "Pruning old preserved packs: "
+    count=$(find "$preserved" -name "*.old-pack" | wc -l)
+    rm -rf "$preserved"
+    echo "$count, done."
+  fi
+}
+
+# preserve packs if gc.preserveoldpacks == true
+preserve_packs() { # repo
+  configured=$(git --git-dir="$1" config --get gc.preserveoldpacks)
+  if [ "$configured" != "true" ]; then
+    return 0
+  fi
+  local packdir=$1/objects/pack
+  pushd "$packdir" >/dev/null || exit 1
+  mkdir -p preserved
+  printf "Preserving packs: "
+  count=0
+  for file in pack-*{.pack,.idx} ; do
+    ln -f "$file" preserved/"$(get_preserved_packfile_name "$file")"
+    if [[ "$file" == pack-*.pack ]]; then
+      ((count++))
+    fi
+  done
+  echo "$count, done."
+  popd >/dev/null || exit 1
+}
+
+# pack-0...2.pack to pack-0...2.old-pack
+# pack-0...2.idx to pack-0...2.old-idx
+get_preserved_packfile_name() { # packfile > preserved_packfile
+  local old=${1/%\.pack/.old-pack}
+  old=${old/%\.idx/.old-idx}
+  echo "$old"
+}
+
+# main
+
+while [ $# -gt 0 ] ; do
+    case "$1" in
+        -u|-h)  usage 0 ;;
+    esac
+    shift
+done
+args=$(git rev-parse --sq-quote "$@")
+
+repopath=$(git rev-parse --git-dir)
+if [ -z "$repopath" ]; then
+  usage 2
+fi
+
+lock "$repopath"
+prune_preserved "$repopath"
+preserve_packs "$repopath"
+git gc ${args:+"$args"} || { echo "git gc failed"; exit "$?"; }
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
index 5b892aa..594903a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
index f15ddae..5221d95 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/_PROJECT",
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
index 467661b..75e895e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects",
     "entries": "PROJECTS_ENTRIES"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
index 5459f11..487cf02 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
index 70e79ca..8b8a163 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index c141bb8..756313a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT",
     "parent": "PARENT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
index 5720f53..8bec9de 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/delete-project~delete"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/delete-project~delete"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
index 86a3c28..4f6a104 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
index e4e2643..e77d83b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
index f6350be..528ef3e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
index d387a3e..d8ebf7f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
@@ -24,7 +24,6 @@
 
 class AbandonChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
   private var createChange: Option[CreateChange] = Some(new CreateChange(projectName))
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
index ae4fa80..692d576 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
@@ -22,7 +22,6 @@
 
 class CheckNewProjectReplica1 extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   private lazy val replicationDuration = replicationDelay + SecondsPerWeightUnit
 
@@ -30,7 +29,6 @@
 
   override def replaceOverride(in: String): String = {
     var next = replaceProperty("http_port1", 8081, in)
-    next = replaceKeyWith("_project", projectName, next)
     super.replaceOverride(next)
   }
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index c283861..d1d9c88 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -22,12 +22,8 @@
 
 class CloneUsingBothProtocols extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private val duration = 2 * numberOfUsers
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .feed(data)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
index 9fef2cf..a7bda3c 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
@@ -22,7 +22,6 @@
 
 class FlushProjectsCache extends CacheFlushSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   override def relativeRuntimeWeight = 2
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index c199dd9..580ae81 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -23,6 +23,7 @@
 class GerritSimulation extends Simulation {
   implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
 
+  private val defaultHostname: String = "localhost"
   protected val numberKey: String = "number"
 
   private val packageName = getClass.getPackage.getName
@@ -42,7 +43,7 @@
   protected val SecondsPerWeightUnit = 2
   val maxExecutionTime: Int = (SecondsPerWeightUnit * relativeRuntimeWeight * powerFactor).toInt
   private var cumulativeWaitTime = 0
-
+  protected var projectName: String = className
   /**
    * How long a scenario step should wait before starting to execute.
    * This is also registering that step's resulting wait time, so that time
@@ -73,16 +74,23 @@
       replaceProperty("parent", "All-Projects", parent.toString)
     case ("project", project) =>
       var precedes = replaceKeyWith("_project", className, project.toString)
-      precedes = replaceOverride(precedes)
+      precedes = replaceProperty("project", getFullProjectName(projectName), precedes)
       replaceProperty("project", precedes)
     case ("url", url) =>
       var in = replaceOverride(url.toString)
-      in = replaceProperty("hostname", "localhost", in)
+      in = replaceProperty("replica_hostname", getProperty("hostname", defaultHostname), in)
+      in = replaceProperty("hostname", defaultHostname, in)
       in = replaceProperty("http_port", 8080, in)
       in = replaceProperty("http_scheme", "http", in)
+      in = replaceProperty("username", "admin", in)
+      in = replaceProperty("context_path", "", in)
       replaceProperty("ssh_port", 29418, in)
   }
 
+  protected def getFullProjectName(projectName: String): String = {
+    getProperty("project_prefix", "") + projectName
+  }
+
   private def replaceProperty(term: String, in: String): String = {
     replaceProperty(term, term, in)
   }
@@ -100,7 +108,7 @@
     val property = packageName + "." + term
     var value = default
     default match {
-      case _: String | _: Double =>
+      case _: String | _: Double | _: Boolean =>
         val propertyValue = Option(System.getProperty(property))
         if (propertyValue.nonEmpty) {
           value = propertyValue.get
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index e2f13a4..53f942d 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.scenarios
 
+import static java.nio.charset.StandardCharsets.UTF_8
 import java.io.{File, IOException}
+import java.net.URLEncoder
 
 import com.github.barbasa.gatling.git.GitRequestSession
 import com.github.barbasa.gatling.git.protocol.GitProtocol
@@ -29,6 +31,14 @@
   protected val gitRequest = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
   protected val gitProtocol: GitProtocol = GitProtocol()
 
+  override def replaceOverride(in: String): String = {
+    var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), UTF_8), in)
+    val authenticated = getProperty("authenticated", false).toBoolean
+    val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+    next = replaceKeyWith("context_path", value, next)
+    super.replaceOverride(next)
+  }
+
   after {
     Thread.sleep(5000)
     val path = conf.tmpBasePath
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
index d6cb937..f0c6f68 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.scenarios
 
+import static java.nio.charset.StandardCharsets.UTF_8
+import java.net.URLEncoder
+
 class ProjectSimulation extends GerritSimulation {
-  protected var projectName: String = "defaultTestProject"
+  projectName = "defaultTestProject"
 
   override def replaceOverride(in: String): String = {
-    replaceProperty("project", projectName, in)
+    replaceProperty("project", URLEncoder.encode(getFullProjectName(projectName), UTF_8, in)
   }
 }
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 0bd9e4a..c049f09 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -22,13 +22,9 @@
 
 class ReplayRecordsFromFeeder extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
 
   override def relativeRuntimeWeight = 30
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .repeat(10) {
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
index 81096b0..756a239 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
@@ -24,7 +24,6 @@
 
 class RestoreChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
index 20be28a..49f0c4b 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
@@ -23,7 +23,6 @@
 
 class SubmitChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
   private var createChange = new CreateChange(projectName)
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
index 9e1431b..ca618c3 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
@@ -25,7 +25,6 @@
 class SubmitChangeInBranch extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
   private var changesCopy: mutable.Queue[Int] = mutable.Queue[Int]()
-  private val projectName = className
 
   override def relativeRuntimeWeight = 10
 
diff --git a/java/Main.java b/java/Main.java
index 11d8234..09c8c76 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+@SuppressWarnings("DefaultPackage")
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
   private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index f7b343b..ccf74d1 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,11 +26,13 @@
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 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.ProjectCache.illegalState;
 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.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
@@ -41,9 +43,9 @@
 import com.google.common.base.Ticker;
 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.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.common.testing.FakeTicker;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
@@ -80,6 +82,7 @@
 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.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -87,6 +90,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -96,9 +100,8 @@
 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.ResourceNotFoundException;
 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.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -125,11 +128,7 @@
 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.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.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -157,15 +156,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 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;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.KeyPair;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -179,6 +170,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -187,7 +179,6 @@
 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;
@@ -197,11 +188,7 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.Transport;
-import org.eclipse.jgit.transport.TransportBundleStream;
-import org.eclipse.jgit.transport.URIish;
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
@@ -313,14 +300,11 @@
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
   @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 PluginGuiceEnvironment pluginGuiceEnvironment;
   @Inject private PluginUser.Factory pluginUserFactory;
-  @Inject private ProjectIndexCollection projectIndexes;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private SitePaths sitePaths;
   @Inject private ProjectOperations projectOperations;
@@ -461,7 +445,6 @@
 
     baseConfig.setInt("index", null, "batchThreads", -1);
 
-    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     Module auditModule = createAuditModule();
     Module sshModule = createSshModule();
@@ -583,8 +566,7 @@
         && SshMode.useSsh()
         && (adminSshSession == null || userSshSession == null)) {
       // Create Ssh sessions
-      KeyPair adminKeyPair = sshKeys.getKeyPair(admin);
-      SshSessionFactory.initSsh(adminKeyPair);
+      SshSessionFactory.initSsh();
       Context ctx = newRequestContext(user);
       atrScope.set(ctx);
       userSshSession = ctx.getSession();
@@ -659,7 +641,10 @@
   }
 
   protected Project.NameKey createProjectOverAPI(
-      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
+      String nameSuffix,
+      @Nullable Project.NameKey parent,
+      boolean createEmptyCommit,
+      @Nullable SubmitType submitType)
       throws RestApiException {
     ProjectInput in = new ProjectInput();
     in.name = name(nameSuffix);
@@ -972,7 +957,7 @@
             repo,
             "new subject",
             "new file",
-            "new content");
+            "new content " + UUID.randomUUID());
     return result;
   }
 
@@ -1069,87 +1054,6 @@
     };
   }
 
-  protected void disableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (!(i instanceof ReadOnlyChangeIndex)) {
-        changeIndexes.addWriteIndex(new ReadOnlyChangeIndex(i));
-      }
-    }
-  }
-
-  protected void enableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (i instanceof ReadOnlyChangeIndex) {
-        changeIndexes.addWriteIndex(((ReadOnlyChangeIndex) i).unwrap());
-      }
-    }
-  }
-
-  protected AutoCloseable disableChangeIndex() {
-    disableChangeIndexWrites();
-    ChangeIndex maybeDisabledSearchIndex = changeIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledChangeIndex)) {
-      changeIndexes.setSearchIndex(new DisabledChangeIndex(maybeDisabledSearchIndex), false);
-    }
-
-    return () -> {
-      enableChangeIndexWrites();
-      ChangeIndex maybeEnabledSearchIndex = changeIndexes.getSearchIndex();
-      if (maybeEnabledSearchIndex instanceof DisabledChangeIndex) {
-        changeIndexes.setSearchIndex(
-            ((DisabledChangeIndex) maybeEnabledSearchIndex).unwrap(), false);
-      }
-    };
-  }
-
-  protected AutoCloseable disableAccountIndex() {
-    AccountIndex maybeDisabledSearchIndex = accountIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledAccountIndex)) {
-      accountIndexes.setSearchIndex(new DisabledAccountIndex(maybeDisabledSearchIndex), 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();
   }
@@ -1163,7 +1067,7 @@
       ProjectConfig config = projectConfigFactory.read(md);
       config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value));
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
@@ -1172,7 +1076,20 @@
       ProjectConfig config = projectConfigFactory.read(md);
       config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value));
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
+    }
+  }
+
+  protected void setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean value)
+      throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.updateProject(
+          p ->
+              p.setBooleanConfig(
+                  BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS, value));
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
@@ -1199,10 +1116,74 @@
     gApi.changes().id(id).current().review(ReviewInput.recommend());
   }
 
+  protected void assertThatAccountIsNotVisible(TestAccount... testAccounts) {
+    for (TestAccount testAccount : testAccounts) {
+      assertThrows(
+          ResourceNotFoundException.class, () -> gApi.accounts().id(testAccount.id().get()).get());
+    }
+  }
+
+  protected void assertReviewers(String changeId, TestAccount... expectedReviewers)
+      throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.REVIEWER);
+    List<Integer> reviewers =
+        reviewerMap.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(reviewers)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedReviewers).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertCcs(String changeId, TestAccount... expectedCcs) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        reviewerMap.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedCcs).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertNoCcs(String changeId) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).doesNotContainKey(ReviewerState.CC);
+  }
+
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
-    List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    assertSubmittedTogether(chId, ImmutableSet.of(), expected);
+  }
+
+  protected void assertSubmittedTogetherWithTopicClosure(String chId, String... expected)
+      throws Exception {
+    assertSubmittedTogether(chId, ImmutableSet.of(TOPIC_CLOSURE), expected);
+  }
+
+  protected void assertSubmittedTogether(
+      String chId,
+      ImmutableSet<SubmittedTogetherOption> submittedTogetherOptions,
+      String... expected)
+      throws Exception {
+    // This does not include NON_VISIBILE_CHANGES
+    List<ChangeInfo> actual =
+        submittedTogetherOptions.isEmpty()
+            ? gApi.changes().id(chId).submittedTogether()
+            : gApi.changes()
+                .id(chId)
+                .submittedTogether(EnumSet.copyOf(submittedTogetherOptions))
+                .changes;
+
+    EnumSet<SubmittedTogetherOption> enumSetIncludingNonVisibleChanges =
+        submittedTogetherOptions.isEmpty()
+            ? EnumSet.of(NON_VISIBLE_CHANGES)
+            : EnumSet.copyOf(submittedTogetherOptions);
+    enumSetIncludingNonVisibleChanges.add(NON_VISIBLE_CHANGES);
+
+    // This includes NON_VISIBLE_CHANGES for comparison.
     SubmittedTogetherInfo info =
-        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+        gApi.changes().id(chId).submittedTogether(enumSetIncludingNonVisibleChanges);
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(Iterables.transform(actual, i1 -> i1.changeId))
@@ -1256,62 +1237,10 @@
     assertThat(replyTo.getString()).contains(email);
   }
 
-  protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
-    try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
-      return fetchFromBundles(result);
-    }
-  }
-
-  /**
-   * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
-   * resulting tree id.
-   *
-   * <p>Omits NoteDb meta refs.
-   */
-  protected Map<BranchNameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
-    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
-
-    FileSystem fs = Jimfs.newFileSystem();
-    Path previewPath = fs.getPath("preview.zip");
-    try (OutputStream out = Files.newOutputStream(previewPath)) {
-      bundles.writeTo(out);
-    }
-    Map<BranchNameKey, ObjectId> ret = new HashMap<>();
-    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, (ClassLoader) null);
-        DirectoryStream<Path> dirStream =
-            Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
-      for (Path p : dirStream) {
-        if (!Files.isRegularFile(p)) {
-          continue;
-        }
-        String bundleName = p.getFileName().toString();
-        int len = bundleName.length();
-        assertThat(bundleName).endsWith(".git");
-        String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = Project.nameKey(repoName);
-        TestRepository<?> localRepo = cloneProject(proj);
-
-        try (InputStream bundleStream = Files.newInputStream(p);
-            TransportBundleStream tbs =
-                new TransportBundleStream(
-                    localRepo.getRepository(), new URIish(bundleName), bundleStream)) {
-          FetchResult fr =
-              tbs.fetch(
-                  NullProgressMonitor.INSTANCE,
-                  Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
-          for (Ref r : fr.getAdvertisedRefs()) {
-            String refName = r.getName();
-            if (RefNames.isNoteDbMetaRef(refName)) {
-              continue;
-            }
-            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(BranchNameKey.create(proj, refName), c.getTree().copy());
-          }
-        }
-      }
-    }
-    assertThat(ret).isNotEmpty();
-    return ret;
+  protected void assertMailNotReplyTo(Message message, String email) throws Exception {
+    assertThat(message.headers()).containsKey("Reply-To");
+    StringEmailHeader replyTo = (StringEmailHeader) message.headers().get("Reply-To");
+    assertThat(replyTo.getString()).doesNotContain(email);
   }
 
   /** Assert that the given branches have the given tree ids. */
@@ -1337,34 +1266,61 @@
     assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
   }
 
-  protected void assertDiffForNewFile(
-      DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
-    List<String> expectedLines = ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+  protected void assertDiffForFullyModifiedFile(
+      DiffInfo diff,
+      String commitName,
+      String path,
+      String expectedContentSideA,
+      String expectedContentSideB)
+      throws Exception {
+    assertDiffForFile(diff, commitName, path);
 
-    assertThat(diff.binary).isNull();
+    ImmutableList<String> expectedOldLines =
+        ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+    ImmutableList<String> expectedNewLines =
+        ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+
+    assertThat(diff.changeType).isEqualTo(ChangeType.MODIFIED);
+
+    assertThat(diff.metaA).isNotNull();
+    assertThat(diff.metaB).isNotNull();
+
+    assertThat(diff.metaA.name).isEqualTo(path);
+    assertThat(diff.metaA.lines).isEqualTo(expectedOldLines.size());
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.lines).isEqualTo(expectedNewLines.size());
+
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.a).containsExactlyElementsIn(expectedOldLines).inOrder();
+    assertThat(contentEntry.b).containsExactlyElementsIn(expectedNewLines).inOrder();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, @Nullable RevCommit commit, String path, String expectedContentSideB)
+      throws Exception {
+    assertDiffForNewFile(diff, commit.name(), path, expectedContentSideB);
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, String commitName, String path, String expectedContentSideB) throws Exception {
+    assertDiffForFile(diff, commitName, path);
+
+    ImmutableList<String> expectedLines =
+        ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+
     assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
-    assertThat(diff.diffHeader).isNotNull();
-    assertThat(diff.intralineStatus).isNull();
-    assertThat(diff.webLinks).isNull();
-    assertThat(diff.editWebLinks).isNull();
 
     assertThat(diff.metaA).isNull();
     assertThat(diff.metaB).isNotNull();
-    assertThat(diff.metaB.commitId).isEqualTo(commit.name());
 
-    String expectedContentType = "text/plain";
-    if (COMMIT_MSG.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
-    } else if (MERGE_LIST.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
-    }
-    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
-
-    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
     assertThat(diff.metaB.name).isEqualTo(path);
-    assertThat(diff.metaB.webLinks).isNull();
+    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
 
-    assertThat(diff.content).hasSize(1);
     DiffInfo.ContentEntry contentEntry = diff.content.get(0);
     assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
     assertThat(contentEntry.a).isNull();
@@ -1375,6 +1331,57 @@
     assertThat(contentEntry.skip).isNull();
   }
 
+  protected void assertDiffForDeletedFile(DiffInfo diff, String path, String expectedContentSideA)
+      throws Exception {
+    assertDiffHeaders(diff);
+
+    ImmutableList<String> expectedOriginalLines =
+        ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+
+    assertThat(diff.changeType).isEqualTo(ChangeType.DELETED);
+
+    assertThat(diff.metaA).isNotNull();
+    assertThat(diff.metaB).isNull();
+
+    assertThat(diff.metaA.name).isEqualTo(path);
+    assertThat(diff.metaA.lines).isEqualTo(expectedOriginalLines.size());
+
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.a).containsExactlyElementsIn(expectedOriginalLines).inOrder();
+    assertThat(contentEntry.b).isNull();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  private void assertDiffForFile(DiffInfo diff, String commitName, String path) throws Exception {
+    assertDiffHeaders(diff);
+
+    assertThat(diff.metaB.commitId).isEqualTo(commitName);
+
+    String expectedContentType = "text/plain";
+    if (COMMIT_MSG.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
+    } else if (MERGE_LIST.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
+    }
+
+    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
+
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.webLinks).isNull();
+  }
+
+  private void assertDiffHeaders(DiffInfo diff) throws Exception {
+    assertThat(diff.binary).isNull();
+    assertThat(diff.diffHeader).isNotNull();
+    assertThat(diff.intralineStatus).isNull();
+    assertThat(diff.webLinks).isNull();
+    assertThat(diff.editWebLinks).isNull();
+  }
+
   protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
     assertThat(info.permittedLabels).isNotNull();
     Collection<String> strs = info.permittedLabels.get(label);
@@ -1386,6 +1393,17 @@
     }
   }
 
+  protected void assertOnlyRemovableLabel(
+      ChangeInfo info, String labelId, String labelValue, TestAccount reviewer) {
+    assertThat(info.removableLabels).hasSize(1);
+    assertThat(info.removableLabels).containsKey(labelId);
+    assertThat(info.removableLabels.get(labelId)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId)).containsKey(labelValue);
+    assertThat(info.removableLabels.get(labelId).get(labelValue)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId).get(labelValue).get(0).email)
+        .isEqualTo(reviewer.email());
+  }
+
   protected void assertPermissions(
       Project.NameKey project,
       GroupReference groupReference,
@@ -1614,6 +1632,13 @@
     }
   }
 
+  protected void clearSubmitRequirements(Project.NameKey project) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().clearSubmitRequirements();
+      u.save();
+    }
+  }
+
   protected void configLabel(String label, LabelFunction func) throws Exception {
     configLabel(label, func, ImmutableList.of());
   }
@@ -1686,7 +1711,7 @@
       projectConfig.commit(metaDataUpdate);
       metaDataUpdate.close();
       metaDataUpdate = null;
-      projectCache.evict(projectConfig.getProject());
+      projectCache.evictAndReindex(projectConfig.getProject());
     }
 
     @Override
diff --git a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
index a4ed80a..4e8d20d 100644
--- a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -50,7 +51,7 @@
 
     public void display(OutputStream displayOutputStream) throws Exception {
       PrintWriter stdout =
-          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, "UTF-8")));
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
       try {
         OutputFormat.JSON
             .newGson()
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 6c9a2b1..da033c1 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -98,6 +98,7 @@
 
   protected static class FakeEmailSenderSubject extends Subject {
     private final FakeEmailSender fakeEmailSender;
+    private String emailTitle;
     private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
@@ -145,6 +146,10 @@
                     ? ((StringEmailHeader) header).getString()
                     : header));
       }
+      EmailHeader titleHeader = message.headers().get("Subject");
+      if (titleHeader instanceof StringEmailHeader) {
+        emailTitle = ((StringEmailHeader) titleHeader).getString();
+      }
 
       return this;
     }
@@ -190,6 +195,15 @@
       return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
     }
 
+    public FakeEmailSenderSubject title(String expectedEmailTitle) {
+      if (!emailTitle.equals(expectedEmailTitle)) {
+        failWithoutActual(
+            fact("Expected email title", expectedEmailTitle),
+            fact("but actual title is", emailTitle));
+      }
+      return this;
+    }
+
     private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
       for (String email : emails) {
         rcpt(type, email);
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 91fbf9e..fe845c0 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -303,6 +303,7 @@
     return pluginInfoFromChangeInfo(changeInfos.get(0));
   }
 
+  @Nullable
   protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
     List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
@@ -331,6 +332,7 @@
    * @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
    * @return decoded list of {@code MyInfo}s.
    */
+  @Nullable
   protected static List<PluginDefinedInfo> decodeRawPluginsList(
       Gson gson, @Nullable Object plugins) {
     if (plugins == null) {
diff --git a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
index 50536d8..c4bf20c 100644
--- a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -162,7 +162,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index c67991d..ff5bc00 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -141,6 +141,10 @@
     return create(username, null, username, null, (String[]) null);
   }
 
+  public TestAccount createValid(String username) throws Exception {
+    return create(username, username + "@example.com", username, username);
+  }
+
   public TestAccount admin() throws Exception {
     return create("admin", "admin@example.com", "Administrator", "Adminny", "Administrators");
   }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index fe6e160..9d237af 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -39,11 +39,9 @@
     "//lib:gson",
     "//lib:guava-retrying",
     "//lib:jgit",
-    "//lib:jgit-ssh-jsch",
     "//lib:jgit-ssh-apache",
-    "//lib:jsch",
     "//lib/commons:compress",
-    "//lib/commons:lang",
+    "//lib/commons:lang3",
     "//lib/flogger:api",
     "//lib/guice",
     "//lib/guice:guice-assistedinject",
@@ -76,12 +74,12 @@
     "//java/com/google/gerrit/gpg/testing:gpg-test-util",
     "//java/com/google/gerrit/git/testing",
     "//java/com/google/gerrit/index/testing",
+    "//lib/errorprone:annotations",
 ]
 
 PGM_DEPLOY_ENV = [
     "//lib:caffeine",
     "//lib:caffeine-guava",
-    "//lib/jackson:jackson-core",
     "//lib/prolog:cafeteria",
 ]
 
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 1ff7d0e..5991646 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -46,6 +46,10 @@
   }
 
   public void assertReindexOf(ChangeInfo info, long expectedCount) {
+    if (expectedCount == 0) {
+      assertThat(countsByChange.asMap()).isEmpty();
+      return;
+    }
     assertThat(countsByChange.asMap()).containsExactly(info._number, expectedCount);
     clear();
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
index 271d15c..7660948 100644
--- a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -45,11 +45,21 @@
   }
 
   @Override
+  public void insert(AccountState obj) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
   public void replace(AccountState obj) {
     throw new UnsupportedOperationException("AccountIndex is disabled");
   }
 
   @Override
+  public void deleteByValue(AccountState value) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
   public void delete(Account.Id key) {
     throw new UnsupportedOperationException("AccountIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index 34f72f5c..c028a8e 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -52,11 +52,21 @@
   }
 
   @Override
+  public void insert(ChangeData obj) {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
   public void replace(ChangeData obj) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
   public void delete(Change.Id key) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
index ed119ff..f2aad4a 100644
--- a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -50,11 +50,21 @@
   }
 
   @Override
+  public void insert(ProjectData obj) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
   public void replace(ProjectData obj) {
     throw new UnsupportedOperationException("ProjectIndex is disabled");
   }
 
   @Override
+  public void deleteByValue(ProjectData value) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
   public void delete(Project.NameKey key) {
     throw new UnsupportedOperationException("ProjectIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 1e5598e..3d90bf0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -14,14 +14,17 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
@@ -36,6 +39,7 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
@@ -48,6 +52,8 @@
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
@@ -70,6 +76,7 @@
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
   private final DynamicSet<SubmitRule> submitRules;
+  private final DynamicSet<SubmitRequirement> submitRequirements;
   private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
   private final DynamicSet<ChangeETagComputation> changeETagComputations;
   private final DynamicSet<ActionVisitor> actionVisitors;
@@ -77,10 +84,12 @@
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
   private final DynamicSet<CommentAddedListener> commentAddedListeners;
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private final DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
   private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
   private final DynamicSet<EditWebLink> editWebLinks;
+  private final DynamicSet<FileWebLink> fileWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -95,6 +104,10 @@
   private final DynamicSet<OnPostReview> onPostReviews;
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
+  private final DynamicSet<AttentionSetListener> attentionSetListeners;
+
+  private final DynamicMap<ChangeHasOperandFactory> hasOperands;
+  private final DynamicMap<ChangeIsOperandFactory> isOperands;
 
   @Inject
   ExtensionRegistry(
@@ -108,6 +121,7 @@
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
       DynamicSet<SubmitRule> submitRules,
+      DynamicSet<SubmitRequirement> submitRequirements,
       DynamicSet<ChangeMessageModifier> changeMessageModifiers,
       DynamicSet<ChangeETagComputation> changeETagComputations,
       DynamicSet<ActionVisitor> actionVisitors,
@@ -115,10 +129,12 @@
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       DynamicSet<CommentAddedListener> commentAddedListeners,
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
+      DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
       DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
       DynamicSet<EditWebLink> editWebLinks,
+      DynamicSet<FileWebLink> fileWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -131,7 +147,10 @@
       DynamicSet<PluginPushOption> pluginPushOption,
       DynamicSet<OnPostReview> onPostReviews,
       DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
-      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners) {
+      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
+      DynamicMap<ChangeHasOperandFactory> hasOperands,
+      DynamicMap<ChangeIsOperandFactory> isOperands,
+      DynamicSet<AttentionSetListener> attentionSetListeners) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -142,6 +161,7 @@
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.submitRules = submitRules;
+    this.submitRequirements = submitRequirements;
     this.changeMessageModifiers = changeMessageModifiers;
     this.changeETagComputations = changeETagComputations;
     this.actionVisitors = actionVisitors;
@@ -149,9 +169,11 @@
     this.refOperationValidationListeners = refOperationValidationListeners;
     this.commentAddedListeners = commentAddedListeners;
     this.refUpdatedListeners = refUpdatedListeners;
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
+    this.fileWebLinks = fileWebLinks;
     this.resolveConflictsWebLinks = resolveConflictsWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
@@ -166,6 +188,9 @@
     this.onPostReviews = onPostReviews;
     this.reviewerAddedListeners = reviewerAddedListeners;
     this.reviewerDeletedListeners = reviewerDeletedListeners;
+    this.hasOperands = hasOperands;
+    this.isOperands = isOperands;
+    this.attentionSetListeners = attentionSetListeners;
   }
 
   public Registration newRegistration() {
@@ -216,6 +241,18 @@
       return add(submitRules, submitRule);
     }
 
+    public Registration add(SubmitRequirement submitRequirement) {
+      return add(submitRequirements, submitRequirement);
+    }
+
+    public Registration add(ChangeHasOperandFactory hasOperand, String exportName) {
+      return add(hasOperands, hasOperand, exportName);
+    }
+
+    public Registration add(ChangeIsOperandFactory isOperand, String exportName) {
+      return add(isOperands, isOperand, exportName);
+    }
+
     public Registration add(ChangeMessageModifier changeMessageModifier) {
       return add(changeMessageModifiers, changeMessageModifier);
     }
@@ -248,6 +285,10 @@
       return add(refUpdatedListeners, refUpdatedListener);
     }
 
+    public Registration add(GitBatchRefUpdateListener batchRefUpdateListener) {
+      return add(batchRefUpdateListeners, batchRefUpdateListener);
+    }
+
     public Registration add(FileHistoryWebLink fileHistoryWebLink) {
       return add(fileHistoryWebLinks, fileHistoryWebLink);
     }
@@ -264,6 +305,10 @@
       return add(editWebLinks, editWebLink);
     }
 
+    public Registration add(FileWebLink fileWebLink) {
+      return add(fileWebLinks, fileWebLink);
+    }
+
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
@@ -289,6 +334,10 @@
       return add(workInProgressStateChangedListeners, workInProgressStateChangedListener);
     }
 
+    public Registration add(AttentionSetListener attentionSetListener) {
+      return add(attentionSetListeners, attentionSetListener);
+    }
+
     public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
       return add(capabilityDefinitions, capabilityDefinition, exportName);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 402d21d..a149f29 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -61,6 +61,7 @@
 import com.google.gerrit.server.experiments.ConfigExperimentFeatures.ConfigExperimentFeaturesModule;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.util.ReplicaUtil;
@@ -492,11 +493,11 @@
     }
     if (indexType.isLucene()) {
       daemon.setIndexModule(
-          LuceneIndexModule.singleVersionAllLatest(0, ReplicaUtil.isReplica(baseConfig)));
+          LuceneIndexModule.singleVersionAllLatest(
+              0, ReplicaUtil.isReplica(baseConfig), AutoFlush.ENABLED));
     } else {
       daemon.setIndexModule(FakeIndexModule.latestVersion(false));
     }
-    // Elastic search is not supported in integration tests yet.
 
     daemon.setEnableHttpd(desc.httpd());
     daemon.setInMemory(true);
diff --git a/java/com/google/gerrit/acceptance/GitClientVersion.java b/java/com/google/gerrit/acceptance/GitClientVersion.java
index 4c9a32d..2415781 100644
--- a/java/com/google/gerrit/acceptance/GitClientVersion.java
+++ b/java/com/google/gerrit/acceptance/GitClientVersion.java
@@ -16,6 +16,8 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Splitter;
+import java.util.List;
 import java.util.stream.IntStream;
 
 /** Class to parse and represent version of git-core client */
@@ -38,11 +40,11 @@
    */
   public GitClientVersion(String version) {
     // "git version x.y.z", at Google "git version x.y.z.gXXXXXXXXXX-goog"
-    String parts[] = version.split(" ")[2].split("\\.");
-    int numParts = Math.min(parts.length, 3); // ignore Google-specific part of the version
+    List<String> parts = Splitter.on(".").splitToList(Splitter.on(" ").splitToList(version).get(2));
+    int numParts = Math.min(parts.size(), 3); // ignore Google-specific part of the version
     v = new int[numParts];
     for (int i = 0; i < numParts; i++) {
-      v[i] = Integer.valueOf(parts[i]);
+      v[i] = Integer.valueOf(parts.get(i));
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index 88079a4..76c0f04 100644
--- a/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
@@ -59,6 +60,7 @@
     return getHeader("X-FYI-Content-Type");
   }
 
+  @Nullable
   public String getHeader(String name) {
     Header hdr = response.getFirstHeader(name);
     return hdr != null ? hdr.getValue() : null;
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index 373246a..9a652e3 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -21,11 +21,13 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GlobalPluginConfigProvider;
+import com.google.gerrit.server.config.MetricsReservoirConfigImpl;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -36,6 +38,7 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
+import com.google.inject.Scopes;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -69,6 +72,7 @@
       bind(InMemoryRepositoryManager.class).in(SINGLETON);
     }
 
+    bind(MetricsReservoirConfig.class).to(MetricsReservoirConfigImpl.class).in(Scopes.SINGLETON);
     bind(MetricMaker.class).to(TestMetricMaker.class);
 
     listener().to(CreateSchema.class);
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 83c63f9..17ce595 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -100,7 +100,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               Context ctx = current.get();
@@ -235,9 +235,9 @@
 
       PermissionBackend.ForProject perm = permissionBackend.currentUser().project(req.project);
       try {
-        perm.check(ProjectPermission.RUN_UPLOAD_PACK);
-      } catch (AuthException e) {
-        throw new ServiceNotAuthorizedException(e.getMessage(), e);
+        if (!perm.test(ProjectPermission.RUN_UPLOAD_PACK)) {
+          throw new ServiceNotAuthorizedException("upload pack not permitted");
+        }
       } catch (PermissionBackendException e) {
         throw new RuntimeException(e);
       }
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index d885303..46f7496 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -307,7 +307,7 @@
         Sets.union(
             projectsWithConfigChanges(restoredRefsByProject),
             projectsWithConfigChanges(deletedRefsByProject))) {
-      projectCache.evict(project);
+      projectCache.evictAndReindex(project);
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index d46fb78..5b1fa9b 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
@@ -263,7 +264,11 @@
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     } else {
-      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      if (subject.contains("\nChange-Id: ")) {
+        commitBuilder = testRepo.amendRef("HEAD");
+      } else {
+        commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      }
     }
     commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
@@ -276,6 +281,12 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
+  public PushOneCommit setTopLevelTreeId(ObjectId treeId) throws Exception {
+    commitBuilder.setTopLevelTree(treeId);
+    return this;
+  }
+
   public PushOneCommit setParent(RevCommit parent) throws Exception {
     commitBuilder.noParents();
     commitBuilder.parent(parent);
@@ -287,6 +298,19 @@
     return this;
   }
 
+  public PushOneCommit addFile(String path, String content, int fileMode) throws Exception {
+    RevBlob blobId = testRepo.blob(content);
+    commitBuilder.edit(
+        new PathEdit(path) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.fromBits(fileMode));
+            ent.setObjectId(blobId);
+          }
+        });
+    return this;
+  }
+
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
diff --git a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
deleted file mode 100644
index e943519..0000000
--- a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.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.acceptance;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-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.server.index.change.ChangeIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-
-class ReadOnlyChangeIndex implements ChangeIndex {
-  private final ChangeIndex index;
-
-  ReadOnlyChangeIndex(ChangeIndex index) {
-    this.index = index;
-  }
-
-  ChangeIndex unwrap() {
-    return index;
-  }
-
-  @Override
-  public Schema<ChangeData> getSchema() {
-    return index.getSchema();
-  }
-
-  @Override
-  public void close() {
-    index.close();
-  }
-
-  @Override
-  public void replace(ChangeData obj) {
-    // do nothing
-  }
-
-  @Override
-  public void delete(Change.Id key) {
-    // do nothing
-  }
-
-  @Override
-  public void deleteAll() {
-    // do nothing
-  }
-
-  @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    return index.getSource(p, opts);
-  }
-
-  @Override
-  public void markReady(boolean ready) {
-    // do nothing
-  }
-}
diff --git a/java/com/google/gerrit/acceptance/SshSessionJsch.java b/java/com/google/gerrit/acceptance/SshSessionJsch.java
deleted file mode 100644
index a86c2d6..0000000
--- a/java/com/google/gerrit/acceptance/SshSessionJsch.java
+++ /dev/null
@@ -1,174 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.nio.charset.StandardCharsets.US_ASCII;
-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;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.Session;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.StringWriter;
-import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
-import java.security.GeneralSecurityException;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.Properties;
-import java.util.Scanner;
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator;
-import org.bouncycastle.util.io.pem.PemObject;
-import org.eclipse.jgit.transport.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.OpenSshConfig.Host;
-import org.eclipse.jgit.transport.SshSessionFactory;
-import org.eclipse.jgit.util.FS;
-
-public class SshSessionJsch extends SshSession {
-
-  private Session session;
-
-  public static void initClient(KeyPair keyPair) {
-    Properties config = new Properties();
-    config.put("StrictHostKeyChecking", "no");
-    JSch.setConfig(config);
-
-    // register a JschConfigSessionFactory that adds the private key as identity
-    // to the JSch instance of JGit so that SSH communication via JGit can
-    // succeed
-    SshSessionFactory.setInstance(
-        new JschConfigSessionFactory() {
-          @Override
-          protected void configure(Host hc, Session session) {
-            try {
-              JSch jsch = getJSch(hc, FS.DETECTED);
-              jsch.addIdentity(
-                  "KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
-            } catch (JSchException | GeneralSecurityException | IOException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        });
-  }
-
-  public static KeyPairGenerator initKeyPairGenerator() throws NoSuchAlgorithmException {
-    KeyPairGenerator gen;
-    gen = KeyPairGenerator.getInstance("RSA");
-    gen.initialize(512, new SecureRandom());
-    return gen;
-  }
-
-  public SshSessionJsch(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
-    super(sshKeys, addr, account);
-  }
-
-  @Override
-  public void open() throws Exception {
-    getJschSession();
-  }
-
-  @Override
-  public void close() {
-    if (session != null) {
-      session.disconnect();
-      session = null;
-    }
-  }
-
-  @SuppressWarnings("resource")
-  @Override
-  public String exec(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream in = channel.getInputStream();
-      InputStream err = channel.getErrStream();
-      channel.connect();
-
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
-
-      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
-      return s.hasNext() ? s.next() : "";
-    } finally {
-      channel.disconnect();
-    }
-  }
-
-  @SuppressWarnings("resource")
-  @Override
-  public int execAndReturnStatus(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream err = channel.getErrStream();
-      channel.connect();
-
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
-      return channel.getExitStatus();
-    } finally {
-      channel.disconnect();
-    }
-  }
-
-  @Override
-  public Reader execAndReturnReader(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
-    channel.setCommand(command);
-    channel.connect();
-
-    return new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8) {
-      @Override
-      public void close() throws IOException {
-        super.close();
-        channel.disconnect();
-      }
-    };
-  }
-
-  private Session getJschSession() throws Exception {
-    if (session == null) {
-      KeyPair keyPair = sshKeys.getKeyPair(account);
-      JSch jsch = new JSch();
-      jsch.addIdentity("KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
-      String username = getUsername();
-      session = jsch.getSession(username, addr.getAddress().getHostAddress(), addr.getPort());
-      session.setConfig("StrictHostKeyChecking", "no");
-      session.connect();
-    }
-    return session;
-  }
-
-  private static byte[] privateKey(KeyPair keyPair) throws IOException {
-    // unencrypted form of PKCS#8 file
-    JcaPKCS8Generator gen1 = new JcaPKCS8Generator(keyPair.getPrivate(), null);
-    PemObject obj1 = gen1.generate();
-    StringWriter sw1 = new StringWriter();
-    try (JcaPEMWriter pw = new JcaPEMWriter(sw1)) {
-      pw.writeObject(obj1);
-    }
-    return sw1.toString().getBytes(US_ASCII.name());
-  }
-}
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index 3b0ba3b..89096e4 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.createTempDirectory;
 
 import com.google.common.io.CharSink;
 import com.google.common.io.Files;
@@ -140,7 +141,7 @@
                   + addr.getPort());
 
       // TODO(davido): Switch to memory only key resolving mode.
-      File userhome = Files.createTempDir();
+      File userhome = createTempDirectory("home-").toFile();
 
       FS fs = FS.DETECTED.setUserHome(userhome);
       File sshDir = new File(userhome, ".ssh");
@@ -168,6 +169,7 @@
                 MoreFiles.deleteRecursively(userhome.toPath(), ALLOW_INSECURE);
               } catch (IOException e) {
                 e.printStackTrace();
+                throw new RuntimeException("Failed to cleanup userhome", e);
               }
             });
       }
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index 2620f99..85c5b6d 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -18,9 +18,8 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.inject.Singleton;
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.commons.lang.mutable.MutableLong;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.commons.lang3.mutable.MutableLong;
 
 /**
  * {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
@@ -49,7 +48,7 @@
  */
 @Singleton
 public class TestMetricMaker extends DisabledMetricMaker {
-  private final Map<String, MutableLong> counts = new HashMap<>();
+  private final ConcurrentHashMap<String, MutableLong> counts = new ConcurrentHashMap<>();
 
   public long getCount(String counter0Name) {
     return get(counter0Name).longValue();
diff --git a/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java b/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java
new file mode 100644
index 0000000..0138ffa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+import java.util.Optional;
+
+/** Implementation of {@link OnStoreSubmitRequirementResultModifier} that is used in tests. */
+public class TestOnStoreSubmitRequirementResultModifier
+    implements OnStoreSubmitRequirementResultModifier {
+
+  private ModificationStrategy modificationStrategy = ModificationStrategy.KEEP;
+
+  private boolean hide = false;
+
+  /**
+   * The strategy, used by this modifier to transform {@link SubmitRequirementResult} on {@link
+   * OnStoreSubmitRequirementResultModifier#modifyResultOnStore} invocations.
+   */
+  public enum ModificationStrategy {
+    KEEP,
+    FAIL,
+    PASS,
+    OVERRIDE
+  }
+
+  public void setModificationStrategy(ModificationStrategy modificationStrategy) {
+    this.modificationStrategy = modificationStrategy;
+  }
+
+  public void hide(boolean hide) {
+    this.hide = hide;
+  }
+
+  @Override
+  public SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult result,
+      ChangeData cd,
+      ChangeContext ctx) {
+    if (modificationStrategy.equals(ModificationStrategy.KEEP)) {
+      return result;
+    }
+    SubmitRequirementResult.Builder srResultBuilder = result.toBuilder().hidden(Optional.of(hide));
+    if (modificationStrategy.equals(ModificationStrategy.OVERRIDE)) {
+      return srResultBuilder
+          .overrideExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      submitRequirement.submittabilityExpression(),
+                      SubmitRequirementExpressionResult.Status.PASS,
+                      ImmutableList.of(),
+                      ImmutableList.of(
+                          submitRequirement.submittabilityExpression().expressionString()))))
+          .build();
+    }
+    return srResultBuilder
+        .submittabilityExpressionResult(
+            SubmitRequirementExpressionResult.create(
+                submitRequirement.submittabilityExpression(),
+                modificationStrategy.equals(ModificationStrategy.FAIL)
+                    ? SubmitRequirementExpressionResult.Status.FAIL
+                    : SubmitRequirementExpressionResult.Status.PASS,
+                ImmutableList.of(),
+                ImmutableList.of(submitRequirement.submittabilityExpression().expressionString())))
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/config/BUILD b/java/com/google/gerrit/acceptance/config/BUILD
index a8ccc1f..0da68b0 100644
--- a/java/com/google/gerrit/acceptance/config/BUILD
+++ b/java/com/google/gerrit/acceptance/config/BUILD
@@ -7,6 +7,7 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
index 24a2117..27ce857 100644
--- a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
+++ b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -27,6 +28,7 @@
 public class ConfigAnnotationParser {
   private static Splitter splitter = Splitter.on(".").trimResults();
 
+  @Nullable
   public static Config parse(Config base, GerritConfigs annotation) {
     if (annotation == null) {
       return null;
@@ -45,6 +47,7 @@
     return cfg;
   }
 
+  @Nullable
   public static Map<String, Config> parse(GlobalPluginConfigs annotation) {
     if (annotation == null || annotation.value().length < 1) {
       return null;
@@ -67,6 +70,7 @@
     return result;
   }
 
+  @Nullable
   public static Map<String, Config> parse(GlobalPluginConfig annotation) {
     if (annotation == null) {
       return null;
diff --git a/java/com/google/gerrit/acceptance/rest/PluginResource.java b/java/com/google/gerrit/acceptance/rest/PluginResource.java
index 745d4fa..56710b9 100644
--- a/java/com/google/gerrit/acceptance/rest/PluginResource.java
+++ b/java/com/google/gerrit/acceptance/rest/PluginResource.java
@@ -20,6 +20,5 @@
 
 public class PluginResource extends ConfigResource {
 
-  static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND =
-      new TypeLiteral<RestView<PluginResource>>() {};
+  static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 3b15b57..c1029be 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -47,9 +47,8 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -138,15 +137,16 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter =
-          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
       ObjectId commitId =
           createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
 
       String refName = RefNames.fullName(changeCreation.branch());
       ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+      changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
+      inserter.setApprovals(changeCreation.approvals());
 
       try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
         batchUpdate.setRepository(repository, revWalk, objectInserter);
@@ -431,7 +431,7 @@
       try (Repository repository = repositoryManager.openRepository(project);
           ObjectInserter objectInserter = repository.newObjectInserter();
           RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-        Timestamp now = TimeUtil.nowTs();
+        Instant now = TimeUtil.now();
         ObjectId newPatchsetCommit =
             createPatchsetCommit(
                 repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
@@ -457,7 +457,7 @@
         ObjectInserter objectInserter,
         ChangeNotes changeNotes,
         TestPatchsetCreation patchsetCreation,
-        Timestamp now)
+        Instant now)
         throws IOException, BadRequestException {
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
       RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
@@ -494,10 +494,10 @@
       return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
     }
 
-    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) {
       PersonIdent oldPatchsetCommitter =
           Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
-      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen())) {
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) {
         /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
          * In real situations, this automatically happens as two patchsets won't have exactly the
          * same commit timestamp even when the tree and commit message are the same. In tests,
@@ -505,13 +505,13 @@
          * We could of course require that tests must use TestTimeUtil#setClockStep but
          * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
          * here and simply add a second. */
-        now = Timestamp.from(now.toInstant().plusSeconds(1));
+        now = now.plusSeconds(1);
       }
       return new PersonIdent(oldPatchsetCommitter, now);
     }
 
-    private long asSeconds(Date date) {
-      return date.getTime() / 1000;
+    private long asSeconds(Instant date) {
+      return date.getEpochSecond();
     }
 
     private ImmutableList<ObjectId> getParents(
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
index d0ccd5b..1971c57 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -28,13 +28,18 @@
 public class FileContentBuilder<T> {
   private final T builder;
   private final String filePath;
+  private final int newGitFileMode;
   private final Consumer<TreeModification> modificationToBuilderAdder;
 
   FileContentBuilder(
-      T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+      T builder,
+      String filePath,
+      int newGitFileMode,
+      Consumer<TreeModification> modificationToBuilderAdder) {
     checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
     this.builder = builder;
     this.filePath = filePath;
+    this.newGitFileMode = newGitFileMode;
     this.modificationToBuilderAdder = modificationToBuilderAdder;
   }
 
@@ -44,7 +49,7 @@
         Strings.emptyToNull(content),
         "Empty file content is not supported. Adjust test API if necessary.");
     modificationToBuilderAdder.accept(
-        new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+        new ChangeFileContentModification(filePath, RawInputUtil.create(content), newGitFileMode));
     return builder;
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java
new file mode 100644
index 0000000..cba9b15
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.change;
+
+import com.google.gerrit.acceptance.DisabledAccountIndex;
+import com.google.gerrit.acceptance.DisabledChangeIndex;
+import com.google.gerrit.acceptance.DisabledProjectIndex;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.inject.Inject;
+
+/** Helpers to enable and disable reads/writes to secondary indices during testing. */
+public interface IndexOperations {
+  /**
+   * Disables reads from the secondary index that this instance is scoped to. Reads fail with {@code
+   * UnsupportedOperationException}.
+   */
+  AutoCloseable disableReads();
+
+  /**
+   * Disables writes to the secondary index that this instance is scoped to. Writes fail with {@code
+   * UnsupportedOperationException}.
+   */
+  AutoCloseable disableWrites();
+
+  /** Disables reads from and writes to the secondary index that this instance is scoped to. */
+  default AutoCloseable disableReadsAndWrites() {
+    AutoCloseable reads = disableReads();
+    AutoCloseable writes = disableWrites();
+    return () -> {
+      reads.close();
+      writes.close();
+    };
+  }
+
+  class Change implements IndexOperations {
+    @Inject private ChangeIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      ChangeIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledChangeIndex)) {
+        indices.setSearchIndex(
+            new DisabledChangeIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        ChangeIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledChangeIndex) {
+          indices.setSearchIndex(
+              ((DisabledChangeIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (ChangeIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledChangeIndex)) {
+          indices.addWriteIndex(new DisabledChangeIndex(i));
+        }
+      }
+      return () -> {
+        for (ChangeIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledChangeIndex) {
+            indices.addWriteIndex(((DisabledChangeIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+
+  class Account implements IndexOperations {
+    @Inject private AccountIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      AccountIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledAccountIndex)) {
+        indices.setSearchIndex(
+            new DisabledAccountIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        AccountIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledAccountIndex) {
+          indices.setSearchIndex(
+              ((DisabledAccountIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (AccountIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledAccountIndex)) {
+          indices.addWriteIndex(new DisabledAccountIndex(i));
+        }
+      }
+      return () -> {
+        for (AccountIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledAccountIndex) {
+            indices.addWriteIndex(((DisabledAccountIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+
+  class Project implements IndexOperations {
+    @Inject private ProjectIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      ProjectIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledProjectIndex)) {
+        indices.setSearchIndex(
+            new DisabledProjectIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        ProjectIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledProjectIndex) {
+          indices.setSearchIndex(
+              ((DisabledProjectIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (ProjectIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledProjectIndex)) {
+          indices.addWriteIndex(new DisabledProjectIndex(i));
+        }
+      }
+      return () -> {
+        for (ProjectIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledProjectIndex) {
+            indices.addWriteIndex(((DisabledProjectIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index eda6c7e..9b393ef 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -38,7 +38,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -106,7 +106,7 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
       IdentifiedUser author = getAuthor(commentCreation);
       CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
@@ -165,8 +165,7 @@
       short side = commentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
       Boolean unresolved = commentCreation.unresolved().orElse(null);
       String parentUuid = commentCreation.parentUuid().orElse(null);
-      Timestamp createdOn =
-          commentCreation.createdOn().map(Timestamp::from).orElse(context.getWhen());
+      Instant createdOn = commentCreation.createdOn().orElse(context.getWhen());
       HumanComment newComment =
           commentsUtil.newHumanComment(
               context.getNotes(),
@@ -202,7 +201,7 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
       IdentifiedUser author = getAuthor(robotCommentCreation);
       RobotCommentAdditionOp robotCommentAdditionOp =
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index 5871e17..f01a138 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -34,6 +35,10 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<String> topic();
+
+  public abstract ImmutableMap<String, Short> approvals();
+
   public abstract String commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -50,7 +55,8 @@
         .branch(Constants.R_HEADS + Constants.MASTER)
         .commitMessage("A test change")
         // Which value we choose here doesn't matter. All relevant code paths set the desired value.
-        .mergeStrategy(MergeStrategy.OURS);
+        .mergeStrategy(MergeStrategy.OURS)
+        .approvals(ImmutableMap.of());
   }
 
   @AutoValue.Builder
@@ -66,6 +72,15 @@
     /** The change owner. Must be an existing user account. */
     public abstract Builder owner(Account.Id owner);
 
+    /** The topic to add this change to. */
+    public abstract Builder topic(String topic);
+
+    /**
+     * The approvals to apply to this change. Map of label name to value. All approvals will be
+     * granted by the uploader.
+     */
+    public abstract Builder approvals(ImmutableMap<String, Short> approvals);
+
     /**
      * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
      * If the footer is absent, it will be generated.
@@ -74,7 +89,18 @@
 
     /** Modified file of the change. The file content is specified via the returned builder. */
     public FileContentBuilder<Builder> file(String filePath) {
-      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+      return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+    }
+
+    /**
+     * Modified file of the change. The file content is specified via the returned builder. The
+     * second parameter indicates the git file mode for the modified file if it has been changed.
+     *
+     * @see org.eclipse.jgit.lib.FileMode
+     */
+    public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+      return new FileContentBuilder<>(
+          this, filePath, newGitFileMode, treeModificationsBuilder()::add);
     }
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index fe9d909..22a4da6 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -45,7 +45,13 @@
 
     /** Modified file of the patchset. The file content is specified via the returned builder. */
     public FileContentBuilder<Builder> file(String filePath) {
-      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+      return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+    }
+
+    /** Modified file of the patchset. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+      return new FileContentBuilder<>(
+          this, filePath, newGitFileMode, treeModificationsBuilder()::add);
     }
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
index c885353..0b21e2c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 @AutoValue
@@ -40,7 +40,7 @@
 
   public abstract boolean visibleToAll();
 
-  public abstract Timestamp createdOn();
+  public abstract Instant createdOn();
 
   public abstract ImmutableSet<Account.Id> members();
 
@@ -67,7 +67,7 @@
 
     public abstract Builder visibleToAll(boolean visibleToAll);
 
-    public abstract Builder createdOn(Timestamp createdOn);
+    public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder members(ImmutableSet<Account.Id> members);
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index f9e2fb5..d34b79a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -8,6 +8,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:function",
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
@@ -17,7 +18,7 @@
         "//lib:jgit-junit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 16dca66..69139ce 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -26,6 +26,7 @@
 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.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
@@ -43,7 +44,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -147,7 +148,7 @@
         setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
         projectConfig.commit(metaDataUpdate);
       }
-      projectCache.evict(nameKey);
+      projectCache.evictAndReindex(nameKey);
     }
 
     private void removePermissions(
@@ -196,8 +197,13 @@
         PermissionRule.Builder 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());
+        String permissionName;
+        if (p.isAddPermission()) {
+          permissionName =
+              p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        } else {
+          permissionName = Permission.forRemoveLabel(p.name());
+        }
         projectConfig.upsertAccessSection(
             p.ref(), as -> as.upsertPermission(permissionName).add(rule));
       }
@@ -213,6 +219,7 @@
                   as -> as.upsertPermission(key.name()).setExclusiveGroup(exclusive)));
     }
 
+    @Nullable
     private RevCommit headOrNull(String branch) {
       branch = RefNames.fullName(branch);
 
@@ -292,7 +299,7 @@
 
         setConfig(projectConfig);
         try {
-          projectCache.evict(nameKey);
+          projectCache.evictAndReindex(nameKey);
         } catch (Exception e) {
           // Evicting the project from the cache, also triggers a reindex of the project.
           // The reindex step fails if the project config is invalid. That's fine, since it was our
@@ -310,7 +317,7 @@
         testProjectInvalidation.projectConfigUpdater().forEach(c -> c.accept(projectConfig));
         setConfig(projectConfig);
         try {
-          projectCache.evict(nameKey);
+          projectCache.evictAndReindex(nameKey);
         } catch (Exception e) {
           // Evicting the project from the cache, also triggers a reindex of the project.
           // The reindex step fails if the project config is invalid. That's fine, since it was our
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 9a9a21a..5634c78 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -162,12 +162,34 @@
 
   /** Starts a builder for allowing a label permission. */
   public static TestLabelPermission.Builder allowLabel(String name) {
-    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .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);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .action(PermissionRule.Action.BLOCK);
+  }
+
+  /** Starts a builder for allowing a remove-label permission. */
+  public static TestLabelPermission.Builder allowLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a remove-label permission. */
+  public static TestLabelPermission.Builder blockLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.BLOCK);
   }
 
   /** Records a label permission to be updated. */
@@ -191,6 +213,8 @@
 
     abstract boolean impersonation();
 
+    abstract boolean isAddPermission();
+
     /** Builder for {@link TestLabelPermission}. */
     @AutoValue.Builder
     public abstract static class Builder {
@@ -208,6 +232,8 @@
 
       abstract Builder max(int max);
 
+      abstract Builder isAddPermission(boolean isAddPermission);
+
       /** Sets the minimum and maximum values for the permission. */
       public Builder range(int min, int max) {
         checkArgument(min != 0 || max != 0, "empty range");
@@ -243,6 +269,12 @@
     return TestPermissionKey.builder().name(Permission.forLabel(name));
   }
 
+  /** Starts a builder for describing a label removal permission key for deletion. */
+  public static TestPermissionKey.Builder labelRemovalPermissionKey(String name) {
+    checkLabelName(name);
+    return TestPermissionKey.builder().name(Permission.forRemoveLabel(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);
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
index d5dd28a..3442b6e 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.acceptance.testsuite.request;
 
-import static com.google.gerrit.server.config.SshClientImplementation.getFromEnvironment;
-
 import com.google.gerrit.acceptance.SshSession;
-import com.google.gerrit.acceptance.SshSessionJsch;
 import com.google.gerrit.acceptance.SshSessionMina;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
@@ -28,25 +25,16 @@
 public class SshSessionFactory {
   public static SshSession createSession(
       TestSshKeys testSshKeys, InetSocketAddress sshAddress, TestAccount testAccount) {
-    return getFromEnvironment().isMina()
-        ? new SshSessionMina(testSshKeys, sshAddress, testAccount)
-        : new SshSessionJsch(testSshKeys, sshAddress, testAccount);
+    return new SshSessionMina(testSshKeys, sshAddress, testAccount);
   }
 
-  public static void initSsh(KeyPair keyPair) {
-    if (getFromEnvironment().isMina()) {
-      SshSessionMina.initClient();
-    } else {
-      SshSessionJsch.initClient(keyPair);
-    }
+  public static void initSsh() {
+    SshSessionMina.initClient();
   }
 
   private SshSessionFactory() {}
 
   public static KeyPair genSshKey() throws GeneralSecurityException {
-    return (getFromEnvironment().isMina()
-            ? SshSessionMina.initKeyPairGenerator()
-            : SshSessionJsch.initKeyPairGenerator())
-        .generateKeyPair();
+    return SshSessionMina.initKeyPairGenerator().generateKeyPair();
   }
 }
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index 513bdd7..acd6aad 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -42,8 +42,8 @@
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexWriterConfig;
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.IndexInput;
-import org.apache.lucene.store.RAMDirectory;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
@@ -92,9 +92,9 @@
     }
   }
 
-  private RAMDirectory index()
+  private ByteBuffersDirectory index()
       throws IOException, UnsupportedEncodingException, FileNotFoundException {
-    RAMDirectory directory = new RAMDirectory();
+    ByteBuffersDirectory directory = new ByteBuffersDirectory();
     IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer(CharArraySet.EMPTY_SET));
     config.setOpenMode(OpenMode.CREATE);
     config.setCommitOnClose(true);
@@ -107,11 +107,19 @@
 
         String title;
         try (BufferedReader titleReader = Files.newBufferedReader(file.toPath(), UTF_8)) {
-          title = titleReader.readLine();
-          if (title != null && title.startsWith("[[")) {
+          while ((title = titleReader.readLine()) != null) {
             // Generally the first line of the txt is the title. In a few cases the
-            // first line is a "[[tag]]" and the second line is the title.
-            title = titleReader.readLine();
+            // first lines are "[[tag]]" and or ":attribute:" and the next line
+            // after those  is the title.
+            if (title.startsWith("[[")) {
+              continue;
+            }
+            // Skip attributes such as :linkattrs:
+            if (title.startsWith(":") && title.endsWith(":")) {
+              continue;
+            }
+            // We found the title
+            break;
           }
         }
         Matcher matcher = SECTION_HEADER.matcher(title);
@@ -132,7 +140,7 @@
     return directory;
   }
 
-  private byte[] zip(RAMDirectory dir) throws IOException {
+  private byte[] zip(ByteBuffersDirectory dir) throws IOException {
     ByteArrayOutputStream buf = new ByteArrayOutputStream();
     try (ZipOutputStream zip = new ZipOutputStream(buf)) {
       for (String name : dir.listAll()) {
diff --git a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
index 8fb4d35..c40baba 100644
--- a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
@@ -39,6 +39,7 @@
     return uuid.get().startsWith(LDAP_UUID);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/auth/ldap/Helper.java b/java/com/google/gerrit/auth/ldap/Helper.java
index a939c72..c11d045 100644
--- a/java/com/google/gerrit/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/auth/ldap/Helper.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.metrics.Description;
@@ -224,6 +225,7 @@
     return ctx;
   }
 
+  @Nullable
   private DirContext kerberosOpen(Properties env)
       throws IOException, LoginException, NamingException {
     LoginContext ctx = new LoginContext("KerberosLogin");
diff --git a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index 2947efd..bb6480a 100644
--- a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -117,6 +117,7 @@
     return isLdapUUID(uuid);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
@@ -125,7 +126,7 @@
 
     String groupDn = uuid.get().substring(LDAP_UUID.length());
     CurrentUser user = userProvider.get();
-    if (!(user.isIdentifiedUser()) || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
+    if (!user.isIdentifiedUser() || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
       try {
         if (!existsCache.get(groupDn)) {
           return null;
diff --git a/java/com/google/gerrit/auth/ldap/LdapQuery.java b/java/com/google/gerrit/auth/ldap/LdapQuery.java
index 409c9f5..71dc141 100644
--- a/java/com/google/gerrit/auth/ldap/LdapQuery.java
+++ b/java/com/google/gerrit/auth/ldap/LdapQuery.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.auth.ldap;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.metrics.Timer0;
 import java.util.ArrayList;
@@ -114,6 +115,7 @@
       return get("dn");
     }
 
+    @Nullable
     String get(String attName) throws NamingException {
       final Attribute att = getAll(attName);
       return att != null && 0 < att.size() ? String.valueOf(att.get(0)) : null;
diff --git a/java/com/google/gerrit/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 9a9f309..7dc2b1b 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -162,6 +163,7 @@
     return vlist;
   }
 
+  @Nullable
   static String optdef(Config c, String n, String d) {
     final String[] v = c.getStringList("ldap", null, n);
     if (v == null || v.length == 0) {
@@ -184,6 +186,7 @@
     return v;
   }
 
+  @Nullable
   static ParameterizedString paramString(Config c, String n, String d) {
     String expression = optdef(c, n, d);
     if (expression == null) {
@@ -199,7 +202,7 @@
       String configOption, String suppliedValue, boolean disabledByBackend) {
     if (disabledByBackend && !Strings.isNullOrEmpty(suppliedValue)) {
       String msg = String.format("LDAP backend doesn't support: ldap.%s", configOption);
-      logger.atSevere().log(msg);
+      logger.atSevere().log("%s", msg);
       throw new IllegalArgumentException(msg);
     }
   }
@@ -209,6 +212,7 @@
     return !readOnlyAccountFields.contains(field);
   }
 
+  @Nullable
   static String apply(ParameterizedString p, LdapQuery.Result m) throws NamingException {
     if (p == null) {
       return null;
@@ -306,6 +310,7 @@
     usernameCache.put(who.getLocalUser(), Optional.of(account.id()));
   }
 
+  @Nullable
   @Override
   public Account.Id lookup(String accountName) {
     if (Strings.isNullOrEmpty(accountName)) {
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index b0c1f51..ab53cde 100644
--- a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Converter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
@@ -109,6 +110,7 @@
     this.encrypter = encrypter;
   }
 
+  @Nullable
   public OAuthToken get(Account.Id id) {
     OAuthToken accessToken = cache.getIfPresent(id);
     if (accessToken == null) {
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index 37f6c2c..09a8993 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -32,16 +32,27 @@
 public final class IoUtil {
   public static void copyWithThread(InputStream src, OutputStream dst) {
     new Thread("IoUtil-Copy") {
+      // We cannot propagate the exception since this code is running in a background thread.
+      // Printing the stacktrace is the best we can do. Hence ignoring the exception after printing
+      // the stacktrace is OK and it's fine to suppress the warning for the CatchAndPrintStackTrace
+      // bug pattern here.
+      @SuppressWarnings("CatchAndPrintStackTrace")
       @Override
       public void run() {
         try {
+          copyIo();
+        } catch (IOException e) {
+          e.printStackTrace();
+        }
+      }
+
+      private void copyIo() throws IOException {
+        try {
           final byte[] buf = new byte[256];
           int n;
           while (0 < (n = src.read(buf))) {
             dst.write(buf, 0, n);
           }
-        } catch (IOException e) {
-          e.printStackTrace();
         } finally {
           try {
             src.close();
diff --git a/java/com/google/gerrit/common/RawInputUtil.java b/java/com/google/gerrit/common/RawInputUtil.java
index 4a676e6..23e4a23 100644
--- a/java/com/google/gerrit/common/RawInputUtil.java
+++ b/java/com/google/gerrit/common/RawInputUtil.java
@@ -14,7 +14,6 @@
 
 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;
 
@@ -31,7 +30,6 @@
 
   public static RawInput create(byte[] bytes, String contentType) {
     requireNonNull(bytes);
-    checkArgument(bytes.length > 0);
     return new RawInput() {
       @Override
       public InputStream getInputStream() throws IOException {
diff --git a/java/com/google/gerrit/common/RuntimeVersion.java b/java/com/google/gerrit/common/RuntimeVersion.java
new file mode 100644
index 0000000..9ab1b96
--- /dev/null
+++ b/java/com/google/gerrit/common/RuntimeVersion.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+/** JDK version string utilities. */
+public final class RuntimeVersion {
+
+  private static final int FEATURE = Runtime.version().feature();
+
+  /** Returns true if the current runtime is JDK 17 or newer. */
+  public static boolean isAtLeast17() {
+    return FEATURE >= 17;
+  }
+
+  /** Returns true if the current runtime is JDK 18 or newer. */
+  public static boolean isAtLeast18() {
+    return FEATURE >= 18;
+  }
+
+  private RuntimeVersion() {}
+}
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 73b1d40..1b87f32 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
 import static java.lang.annotation.ElementType.FIELD;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.ElementType.TYPE;
@@ -25,24 +26,26 @@
 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.
+ * A marker to say a method/type/field/constructor 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})
+@Target({METHOD, TYPE, FIELD, CONSTRUCTOR})
 @Retention(RUNTIME)
 @Repeatable(UsedAt.Uses.class)
 public @interface UsedAt {
   /** Enumeration of projects that call a method/type/field. */
   enum Project {
-    GOOGLE,
     COLLABNET,
+    GOOGLE,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
     PLUGIN_CHECKS,
     PLUGIN_CODE_OWNERS,
     PLUGIN_DELETE_PROJECT,
-    PLUGIN_SERVICEUSER,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
-    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
+    PLUGIN_SERVICEUSER,
+    PLUGIN_WEBSESSION_FLATFILE,
+    MODULE_GIT_REFS_FILTER
   }
 
   /** Reference to the project that uses the method annotated with this annotation. */
diff --git a/java/com/google/gerrit/common/Version.java b/java/com/google/gerrit/common/Version.java
index 6197be5..bfca4d0 100644
--- a/java/com/google/gerrit/common/Version.java
+++ b/java/com/google/gerrit/common/Version.java
@@ -54,7 +54,7 @@
         return vs;
       }
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
       return "(unknown version)";
     }
   }
diff --git a/java/com/google/gerrit/common/data/FilenameComparator.java b/java/com/google/gerrit/common/data/FilenameComparator.java
index ebf423c..0b188df 100644
--- a/java/com/google/gerrit/common/data/FilenameComparator.java
+++ b/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -24,7 +24,7 @@
   public static final FilenameComparator INSTANCE = new FilenameComparator();
 
   private static final Set<String> cppHeaderSuffixes =
-      new HashSet<>(Arrays.asList(".h", ".hxx", ".hpp"));
+      new HashSet<>(Arrays.asList(".h", ".hh", ".hxx", ".hpp"));
 
   private FilenameComparator() {}
 
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 253266d..0a42d09 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
 import java.util.ArrayList;
@@ -190,6 +191,7 @@
   }
 
   /** Returns the valid range for the capability if it has one, otherwise null. */
+  @Nullable
   public static PermissionRange.WithDefaults getRange(String varName) {
     if (QUERY_LIMIT.equalsIgnoreCase(varName)) {
       return new PermissionRange.WithDefaults(
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
deleted file mode 100644
index 562464d..0000000
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ /dev/null
@@ -1,408 +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 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 com.google.common.collect.ArrayListMultimap;
-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.BaseEncoding;
-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.entities.converter.ProtoConverter;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.QueryOptions;
-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.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.protobuf.MessageLite;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-import org.apache.http.HttpEntity;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-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> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  protected static final String BULK = "_bulk";
-  protected static final String MAPPINGS = "mappings";
-  protected static final String ORDER = "order";
-  protected static final String DESC_SORT_ORDER = "desc";
-  protected static final String ASC_SORT_ORDER = "asc";
-  protected static final String UNMAPPED_TYPE = "unmapped_type";
-  protected static final String SEARCH = "_search";
-  protected static final String SETTINGS = "settings";
-
-  static byte[] decodeBase64(String base64String) {
-    return BaseEncoding.base64().decode(base64String);
-  }
-
-  protected static <T> List<T> decodeProtos(
-      JsonObject doc, String fieldName, ProtoConverter<?, T> converter) {
-    JsonArray field = doc.getAsJsonArray(fieldName);
-    if (field == null) {
-      return null;
-    }
-    return Streams.stream(field)
-        .map(JsonElement::getAsString)
-        .map(AbstractElasticIndex::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 {
-    HttpEntity responseEntity = response.getEntity();
-    String content = "";
-    if (responseEntity != null) {
-      InputStream contentStream = responseEntity.getContent();
-      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;
-
-  protected final ElasticRestClientProvider client;
-  protected final String indexName;
-  protected final Gson gson;
-  protected final ElasticQueryBuilder queryBuilder;
-
-  AbstractElasticIndex(
-      ElasticConfiguration config,
-      SitePaths sitePaths,
-      Schema<V> schema,
-      ElasticRestClientProvider client,
-      String indexName) {
-    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 = config.getIndexName(indexName, schema.getVersion());
-    this.indexNameRaw = indexName;
-    this.client = client;
-  }
-
-  @Override
-  public Schema<V> getSchema() {
-    return schema;
-  }
-
-  @Override
-  public void close() {
-    // Do nothing. Client is closed by the provider.
-  }
-
-  @Override
-  public void markReady(boolean ready) {
-    IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready);
-  }
-
-  @Override
-  public void delete(K id) {
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, getDeleteActions(id), getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format("Failed to delete %s from index %s: %s", id, indexName, statusCode));
-    }
-  }
-
-  @Override
-  public void deleteAll() {
-    // Delete the index, if it exists.
-    String endpoint = indexName + client.adapter().indicesExistParams();
-    Response response = performRequest("HEAD", endpoint);
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode == HttpStatus.SC_OK) {
-      response = performRequest("DELETE", indexName);
-      statusCode = response.getStatusLine().getStatusCode();
-      if (statusCode != HttpStatus.SC_OK) {
-        throw new StorageException(
-            String.format("Failed to delete index %s: %s", indexName, statusCode));
-      }
-    }
-
-    // Recreate the index.
-    String indexCreationFields = concatJsonString(getSettings(), getMappings());
-    response = performRequest("PUT", indexName, indexCreationFields);
-    statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      String error = String.format("Failed to create index %s: %s", indexName, statusCode);
-      throw new StorageException(error);
-    }
-  }
-
-  protected abstract String getDeleteActions(K id);
-
-  protected abstract String getMappings();
-
-  private String getSettings() {
-    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config)));
-  }
-
-  protected abstract String getId(V v);
-
-  protected String getMappingsForSingleType(MappingProperties properties) {
-    return getMappingsFor(properties);
-  }
-
-  protected String getMappingsFor(MappingProperties properties) {
-    JsonObject mappings = new JsonObject();
-
-    mappings.add(MAPPINGS, gson.toJsonTree(properties));
-    return gson.toJson(mappings);
-  }
-
-  protected String getDeleteRequest(K id) {
-    return new DeleteRequest(id.toString(), indexName).toString();
-  }
-
-  protected abstract V fromDocument(JsonObject doc, Set<String> fields);
-
-  protected FieldBundle toFieldBundle(JsonObject doc) {
-    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
-    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
-    for (Map.Entry<String, JsonElement> element :
-        doc.get(client.adapter().rawFieldsKey()).getAsJsonObject().entrySet()) {
-      checkArgument(
-          allFields.containsKey(element.getKey()), "Unrecognized field " + element.getKey());
-      FieldType<?> type = allFields.get(element.getKey()).getType();
-      Iterable<JsonElement> innerItems =
-          element.getValue().isJsonArray()
-              ? element.getValue().getAsJsonArray()
-              : Collections.singleton(element.getValue());
-      for (JsonElement inner : innerItems) {
-        if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
-          rawFields.put(element.getKey(), inner.getAsString());
-        } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
-          rawFields.put(element.getKey(), inner.getAsInt());
-        } else if (type == FieldType.LONG) {
-          rawFields.put(element.getKey(), inner.getAsLong());
-        } else if (type == FieldType.TIMESTAMP) {
-          rawFields.put(element.getKey(), new Timestamp(inner.getAsLong()));
-        } else if (type == FieldType.STORED_ONLY) {
-          rawFields.put(element.getKey(), decodeBase64(inner.getAsString()));
-        } else {
-          throw FieldType.badFieldType(type);
-        }
-      }
-    }
-    return new FieldBundle(rawFields);
-  }
-
-  protected String toAction(String type, String id, String action) {
-    JsonObject properties = new JsonObject();
-    properties.addProperty("_id", id);
-    properties.addProperty("_index", indexName);
-    properties.addProperty("_type", type);
-
-    JsonObject jsonAction = new JsonObject();
-    jsonAction.add(action, properties);
-    return jsonAction.toString() + System.lineSeparator();
-  }
-
-  protected void addNamedElement(String name, JsonObject element, JsonArray array) {
-    JsonObject arrayElement = new JsonObject();
-    arrayElement.add(name, element);
-    array.add(arrayElement);
-  }
-
-  protected Map<String, String> getRefreshParam() {
-    Map<String, String> params = new HashMap<>();
-    params.put("refresh", "true");
-    return params;
-  }
-
-  protected String getSearch(SearchSourceBuilder searchSource, JsonArray sortArray) {
-    JsonObject search = new JsonParser().parse(searchSource.toString()).getAsJsonObject();
-    search.add("sort", sortArray);
-    return gson.toJson(search);
-  }
-
-  protected JsonArray getSortArray(String idFieldName) {
-    JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, ASC_SORT_ORDER);
-
-    JsonArray sortArray = new JsonArray();
-    addNamedElement(idFieldName, properties, sortArray);
-    return sortArray;
-  }
-
-  protected String getURI(String request) {
-    try {
-      return URLEncoder.encode(indexName, UTF_8.toString()) + "/" + request;
-    } catch (UnsupportedEncodingException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  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, 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> {
-    private final QueryOptions opts;
-    private final String search;
-
-    ElasticQuerySource(Predicate<V> p, QueryOptions opts, JsonArray sortArray)
-        throws QueryParseException {
-      this.opts = opts;
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder(client.adapter())
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(opts.fields()));
-      search = getSearch(searchSource, sortArray);
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<V> read() {
-      return readImpl(doc -> AbstractElasticIndex.this.fromDocument(doc, opts.fields()));
-    }
-
-    @Override
-    public ResultSet<FieldBundle> readRaw() {
-      return readImpl(AbstractElasticIndex.this::toFieldBundle);
-    }
-
-    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) {
-      try {
-        String uri = getURI(SEARCH);
-        Response response =
-            performRequest(HttpPost.METHOD_NAME, uri, search, Collections.emptyMap());
-        StatusLine statusLine = response.getStatusLine();
-        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
-          String content = getContent(response);
-          JsonObject obj =
-              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            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());
-        }
-        return new ListResultSet<>(ImmutableList.of());
-      } catch (IOException e) {
-        throw new StorageException(e);
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
deleted file mode 100644
index 8bab80b..0000000
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ /dev/null
@@ -1,33 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "elasticsearch",
-    srcs = glob(["**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/entities",
-        "//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/server",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib:protobuf",
-        "//lib/commons:lang",
-        "//lib/elasticsearch-rest-client",
-        "//lib/flogger:api",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/httpcomponents:httpasyncclient",
-        "//lib/httpcomponents:httpclient",
-        "//lib/httpcomponents:httpcore",
-        "//lib/httpcomponents:httpcore-nio",
-        "//lib/jackson:jackson-core",
-    ],
-)
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
deleted file mode 100644
index 8967789..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ /dev/null
@@ -1,143 +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.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-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.entities.Account;
-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;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.account.AccountField;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
-    implements AccountIndex {
-  static class AccountMapping {
-    final MappingProperties accounts;
-
-    AccountMapping(Schema<AccountState> schema, ElasticQueryAdapter adapter) {
-      this.accounts = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String ACCOUNTS = "accounts";
-
-  private final AccountMapping mapping;
-  private final Provider<AccountCache> accountCache;
-  private final Schema<AccountState> schema;
-
-  @Inject
-  ElasticAccountIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<AccountCache> accountCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<AccountState> schema) {
-    super(cfg, sitePaths, schema, client, ACCOUNTS);
-    this.accountCache = accountCache;
-    this.mapping = new AccountMapping(schema, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(AccountState as) {
-    BulkRequest bulk =
-        new IndexRequest(getId(as), indexName)
-            .add(new UpdateRequest<>(schema, as, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace account %s in index %s: %s",
-              as.account().id(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray =
-        getSortArray(
-            schema.useLegacyNumericFields()
-                ? AccountField.ID.getName()
-                : AccountField.ID_STR.getName());
-    return new ElasticQuerySource(
-        p,
-        opts.filterFields(o -> IndexUtils.accountFields(o, schema.useLegacyNumericFields())),
-        sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(Account.Id a) {
-    return getDeleteRequest(a);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.accounts);
-  }
-
-  @Override
-  protected String getId(AccountState as) {
-    return as.account().id().toString();
-  }
-
-  @Override
-  protected AccountState fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    Account.Id id =
-        Account.id(
-            source
-                .getAsJsonObject()
-                .get(
-                    schema.useLegacyNumericFields()
-                        ? AccountField.ID.getName()
-                        : AccountField.ID_STR.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.
-    // If the account doesn't exist return an empty AccountState to represent the missing account
-    // to account the fact that the account exists in the index.
-    return accountCache.get().getEvenIfMissing(id);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
deleted file mode 100644
index 7d4e0c7..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ /dev/null
@@ -1,181 +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 static java.util.Objects.requireNonNull;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-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.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.converter.ChangeProtoConverter;
-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.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.change.MergeabilityComputationBehavior;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.client.Response;
-
-/** Secondary index implementation using Elasticsearch. */
-class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
-    implements ChangeIndex {
-  static class ChangeMapping {
-    final MappingProperties changes;
-    final MappingProperties openChanges;
-    final MappingProperties closedChanges;
-
-    ChangeMapping(Schema<ChangeData> schema, ElasticQueryAdapter adapter) {
-      MappingProperties mapping = ElasticMapping.createMapping(schema, adapter);
-      this.changes = mapping;
-      this.openChanges = mapping;
-      this.closedChanges = mapping;
-    }
-  }
-
-  private static final String CHANGES = "changes";
-
-  private final ChangeMapping mapping;
-  private final ChangeData.Factory changeDataFactory;
-  private final Schema<ChangeData> schema;
-  private final FieldDef<ChangeData, ?> idField;
-  private final ImmutableSet<String> skipFields;
-
-  @Inject
-  ElasticChangeIndex(
-      ElasticConfiguration cfg,
-      ChangeData.Factory changeDataFactory,
-      SitePaths sitePaths,
-      ElasticRestClientProvider clientBuilder,
-      @GerritServerConfig Config gerritConfig,
-      @Assisted Schema<ChangeData> schema) {
-    super(cfg, sitePaths, schema, clientBuilder, CHANGES);
-    this.changeDataFactory = changeDataFactory;
-    this.schema = schema;
-    this.mapping = new ChangeMapping(schema, client.adapter());
-    this.idField =
-        this.schema.useLegacyNumericFields() ? ChangeField.LEGACY_ID : ChangeField.LEGACY_ID_STR;
-    this.skipFields =
-        MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex()
-            ? ImmutableSet.of()
-            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
-  }
-
-  @Override
-  public void replace(ChangeData cd) {
-    BulkRequest bulk =
-        new IndexRequest(getId(cd), indexName).add(new UpdateRequest<>(schema, cd, skipFields));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace change %s in index %s: %s", cd.getId(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    QueryOptions filteredOpts =
-        opts.filterFields(o -> IndexUtils.changeFields(o, schema.useLegacyNumericFields()));
-    return new ElasticQuerySource(p, filteredOpts, getSortArray());
-  }
-
-  private JsonArray getSortArray() {
-    JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, DESC_SORT_ORDER);
-
-    JsonArray sortArray = new JsonArray();
-    addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
-    addNamedElement(ChangeField.MERGED_ON.getName(), getMergedOnSortOptions(), sortArray);
-    addNamedElement(idField.getName(), properties, sortArray);
-    return sortArray;
-  }
-
-  private JsonObject getMergedOnSortOptions() {
-    JsonObject sortOptions = new JsonObject();
-    sortOptions.addProperty(ORDER, DESC_SORT_ORDER);
-    // Ignore the sort field if it does not exist in index. Otherwise the search would fail on open
-    // changes, because the corresponding documents do not have mergedOn field.
-    sortOptions.addProperty(UNMAPPED_TYPE, ElasticMapping.TIMESTAMP_FIELD_TYPE);
-    return sortOptions;
-  }
-
-  @Override
-  protected String getDeleteActions(Change.Id c) {
-    return getDeleteRequest(c);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsFor(mapping.changes);
-  }
-
-  @Override
-  protected String getId(ChangeData cd) {
-    return cd.getId().toString();
-  }
-
-  @Override
-  protected ChangeData fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement sourceElement = json.get("_source");
-    if (sourceElement == null) {
-      sourceElement = json.getAsJsonObject().get("fields");
-    }
-    JsonObject source = sourceElement.getAsJsonObject();
-    JsonElement c = source.get(ChangeField.CHANGE.getName());
-
-    if (c == null) {
-      int id = source.get(idField.getName()).getAsInt();
-      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-      String projectName = requireNonNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-      return changeDataFactory.create(Project.nameKey(projectName), Change.id(id));
-    }
-
-    ChangeData cd =
-        changeDataFactory.create(
-            parseProtoFrom(decodeBase64(c.getAsString()), ChangeProtoConverter.INSTANCE));
-
-    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
-      if (fields.contains(field.getName()) && source.get(field.getName()) != null) {
-        field.setIfPossible(cd, new ElasticStoredValue(source.get(field.getName())));
-      }
-    }
-
-    return cd;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
deleted file mode 100644
index c4435297..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ /dev/null
@@ -1,137 +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 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.List;
-import java.util.concurrent.TimeUnit;
-import org.apache.http.HttpHost;
-import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.client.RestClientBuilder;
-
-@Singleton
-class ElasticConfiguration {
-  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 KEY_MAX_RESULT_WINDOW = "maxResultWindow";
-  static final String KEY_CONNECT_TIMEOUT = "connectTimeout";
-  static final String KEY_SOCKET_TIMEOUT = "socketTimeout";
-
-  static final String DEFAULT_PORT = "9200";
-  static final String DEFAULT_USERNAME = "elastic";
-  static final int DEFAULT_NUMBER_OF_SHARDS = 1;
-  static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
-  static final int DEFAULT_MAX_RESULT_WINDOW = 10000;
-  static final int DEFAULT_CONNECT_TIMEOUT = RestClientBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS;
-  static final int DEFAULT_SOCKET_TIMEOUT = RestClientBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS;
-
-  private final Config cfg;
-  private final List<HttpHost> hosts;
-
-  final String username;
-  final String password;
-  final int numberOfShards;
-  final int numberOfReplicas;
-  final int maxResultWindow;
-  final int connectTimeout;
-  final int socketTimeout;
-  final String prefix;
-
-  @Inject
-  ElasticConfiguration(@GerritServerConfig Config cfg) {
-    this.cfg = cfg;
-    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.maxResultWindow =
-        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_MAX_RESULT_WINDOW, DEFAULT_MAX_RESULT_WINDOW);
-    this.connectTimeout =
-        (int)
-            cfg.getTimeUnit(
-                SECTION_ELASTICSEARCH,
-                null,
-                KEY_CONNECT_TIMEOUT,
-                DEFAULT_CONNECT_TIMEOUT,
-                TimeUnit.MILLISECONDS);
-    this.socketTimeout =
-        (int)
-            cfg.getTimeUnit(
-                SECTION_ELASTICSEARCH,
-                null,
-                KEY_SOCKET_TIMEOUT,
-                DEFAULT_SOCKET_TIMEOUT,
-                TimeUnit.MILLISECONDS);
-    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);
-  }
-
-  int getNumberOfShards() {
-    return numberOfShards;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticException.java b/java/com/google/gerrit/elasticsearch/ElasticException.java
deleted file mode 100644
index d4baf75..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticException.java
+++ /dev/null
@@ -1,27 +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.elasticsearch;
-
-class ElasticException extends RuntimeException {
-  private static final long serialVersionUID = 1L;
-
-  ElasticException(String message) {
-    super(message);
-  }
-
-  ElasticException(String message, Throwable cause) {
-    super(message, cause);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
deleted file mode 100644
index 781ed43..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ /dev/null
@@ -1,126 +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.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-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.entities.AccountGroup;
-import com.google.gerrit.entities.InternalGroup;
-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;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
-    implements GroupIndex {
-  static class GroupMapping {
-    final MappingProperties groups;
-
-    GroupMapping(Schema<InternalGroup> schema, ElasticQueryAdapter adapter) {
-      this.groups = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String GROUPS = "groups";
-
-  private final GroupMapping mapping;
-  private final Provider<GroupCache> groupCache;
-  private final Schema<InternalGroup> schema;
-
-  @Inject
-  ElasticGroupIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<GroupCache> groupCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<InternalGroup> schema) {
-    super(cfg, sitePaths, schema, client, GROUPS);
-    this.groupCache = groupCache;
-    this.mapping = new GroupMapping(schema, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(InternalGroup group) {
-    BulkRequest bulk =
-        new IndexRequest(getId(group), indexName)
-            .add(new UpdateRequest<>(schema, group, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace group %s in index %s: %s",
-              group.getGroupUUID().get(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  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), sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(AccountGroup.UUID g) {
-    return getDeleteRequest(g);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.groups);
-  }
-
-  @Override
-  protected String getId(InternalGroup group) {
-    return group.getGroupUUID().get();
-  }
-
-  @Override
-  protected InternalGroup fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    AccountGroup.UUID uuid =
-        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
deleted file mode 100644
index 15d6126..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.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.elasticsearch;
-
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.server.index.AbstractIndexModule;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import java.util.Map;
-
-public class ElasticIndexModule extends AbstractIndexModule {
-  public static ElasticIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean slave) {
-    return new ElasticIndexModule(versions, threads, slave);
-  }
-
-  public static ElasticIndexModule latestVersion(boolean slave) {
-    return new ElasticIndexModule(null, 0, slave);
-  }
-
-  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
-    super(singleVersions, threads, slave);
-  }
-
-  @Override
-  public void configure() {
-    super.configure();
-    install(ElasticRestClientProvider.module());
-  }
-
-  @Override
-  protected Class<? extends AccountIndex> getAccountIndex() {
-    return ElasticAccountIndex.class;
-  }
-
-  @Override
-  protected Class<? extends ChangeIndex> getChangeIndex() {
-    return ElasticChangeIndex.class;
-  }
-
-  @Override
-  protected Class<? extends GroupIndex> getGroupIndex() {
-    return ElasticGroupIndex.class;
-  }
-
-  @Override
-  protected Class<? extends ProjectIndex> getProjectIndex() {
-    return ElasticProjectIndex.class;
-  }
-
-  @Override
-  protected Class<? extends VersionManager> getVersionManager() {
-    return ElasticIndexVersionManager.class;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
deleted file mode 100644
index 100022a..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
+++ /dev/null
@@ -1,61 +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 static java.util.stream.Collectors.toList;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gson.JsonParser;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.elasticsearch.client.Request;
-import org.elasticsearch.client.Response;
-
-@Singleton
-class ElasticIndexVersionDiscovery {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ElasticRestClientProvider client;
-
-  @Inject
-  ElasticIndexVersionDiscovery(ElasticRestClientProvider client) {
-    this.client = client;
-  }
-
-  List<String> discover(String prefix, String indexName) throws IOException {
-    String name = prefix + indexName + "_";
-    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) {
-      String message =
-          String.format(
-              "Failed to discover index versions for %s: %d: %s",
-              name, statusLine.getStatusCode(), statusLine.getReasonPhrase());
-      logger.atSevere().log(message);
-      throw new IOException(message);
-    }
-
-    return new JsonParser()
-        .parse(AbstractElasticIndex.getContent(response)).getAsJsonObject().entrySet().stream()
-            .map(e -> e.getKey().replace(name, ""))
-            .collect(toList());
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
deleted file mode 100644
index b9d86d5..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.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.elasticsearch;
-
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.GerritIndexStatus;
-import com.google.gerrit.server.index.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;
-import java.util.Collection;
-import java.util.List;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class ElasticIndexVersionManager extends VersionManager {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final String prefix;
-  private final ElasticIndexVersionDiscovery versionDiscovery;
-
-  @Inject
-  ElasticIndexVersionManager(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      PluginSetContext<OnlineUpgradeListener> listeners,
-      Collection<IndexDefinition<?, ?, ?>> defs,
-      ElasticIndexVersionDiscovery versionDiscovery) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
-    this.versionDiscovery = versionDiscovery;
-    prefix = Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix"));
-  }
-
-  @Override
-  protected <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
-      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
-    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
-    try {
-      List<String> discovered = versionDiscovery.discover(prefix, def.getName());
-      logger.atFine().log("Discovered versions for %s: %s", def.getName(), discovered);
-      for (String version : discovered) {
-        Integer v = Ints.tryParse(version);
-        if (v == null || version.length() != 4) {
-          logger.atWarning().log("Unrecognized version in index %s: %s", def.getName(), version);
-          continue;
-        }
-        versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v)));
-      }
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Error scanning index: %s", def.getName());
-    }
-
-    for (Schema<V> schema : def.getSchemas().values()) {
-      int v = schema.getVersion();
-      boolean exists = versions.containsKey(v);
-      versions.put(v, new Version<>(schema, v, exists, cfg.getReady(def.getName(), v)));
-    }
-    return versions;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
deleted file mode 100644
index edd05c9..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ /dev/null
@@ -1,123 +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.common.collect.ImmutableMap;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.Schema;
-import java.util.Map;
-
-class ElasticMapping {
-
-  protected static final String TIMESTAMP_FIELD_TYPE = "date";
-  protected static final String TIMESTAMP_FIELD_FORMAT = "dateOptionalTime";
-
-  static MappingProperties createMapping(Schema<?> schema, ElasticQueryAdapter adapter) {
-    ElasticMapping.Builder mapping = new ElasticMapping.Builder(adapter);
-    for (FieldDef<?, ?> field : schema.getFields().values()) {
-      String name = field.getName();
-      FieldType<?> fieldType = field.getType();
-      if (fieldType == FieldType.EXACT) {
-        mapping.addExactField(name);
-      } else if (fieldType == FieldType.TIMESTAMP) {
-        mapping.addTimestamp(name);
-      } else if (fieldType == FieldType.INTEGER
-          || fieldType == FieldType.INTEGER_RANGE
-          || fieldType == FieldType.LONG) {
-        mapping.addNumber(name);
-      } else if (fieldType == FieldType.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());
-      }
-    }
-    return mapping.build();
-  }
-
-  static class Builder {
-    private final ElasticQueryAdapter adapter;
-    private final ImmutableMap.Builder<String, FieldProperties> fields =
-        new ImmutableMap.Builder<>();
-
-    Builder(ElasticQueryAdapter adapter) {
-      this.adapter = adapter;
-    }
-
-    MappingProperties build() {
-      MappingProperties properties = new MappingProperties();
-      properties.properties = fields.build();
-      return properties;
-    }
-
-    Builder addExactField(String name) {
-      FieldProperties key = new FieldProperties(adapter.exactFieldType());
-      key.index = adapter.indexProperty();
-      FieldProperties properties;
-      properties = new FieldProperties(adapter.exactFieldType());
-      properties.fields = ImmutableMap.of("key", key);
-      fields.put(name, properties);
-      return this;
-    }
-
-    Builder addTimestamp(String name) {
-      FieldProperties properties = new FieldProperties(TIMESTAMP_FIELD_TYPE);
-      properties.type = TIMESTAMP_FIELD_TYPE;
-      properties.format = TIMESTAMP_FIELD_FORMAT;
-      fields.put(name, properties);
-      return this;
-    }
-
-    Builder addNumber(String name) {
-      fields.put(name, new FieldProperties("long"));
-      return this;
-    }
-
-    Builder addString(String name) {
-      fields.put(name, new FieldProperties(adapter.stringFieldType()));
-      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;
-    }
-  }
-
-  static class MappingProperties {
-    Map<String, FieldProperties> properties;
-  }
-
-  static class FieldProperties {
-    String type;
-    String index;
-    String format;
-    String analyzer;
-    Map<String, FieldProperties> fields;
-
-    FieldProperties(String type) {
-      this.type = type;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
deleted file mode 100644
index b8bfc38..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ /dev/null
@@ -1,130 +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.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-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.entities.Project;
-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;
-import com.google.gerrit.index.project.ProjectField;
-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.index.query.QueryParseException;
-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.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Optional;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticProjectIndex extends AbstractElasticIndex<Project.NameKey, ProjectData>
-    implements ProjectIndex {
-  static class ProjectMapping {
-    MappingProperties projects;
-
-    ProjectMapping(Schema<ProjectData> schema, ElasticQueryAdapter adapter) {
-      this.projects = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  static final String PROJECTS = "projects";
-
-  private final ProjectMapping mapping;
-  private final Provider<ProjectCache> projectCache;
-  private final Schema<ProjectData> schema;
-
-  @Inject
-  ElasticProjectIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<ProjectCache> projectCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<ProjectData> schema) {
-    super(cfg, sitePaths, schema, client, PROJECTS);
-    this.projectCache = projectCache;
-    this.schema = schema;
-    this.mapping = new ProjectMapping(schema, client.adapter());
-  }
-
-  @Override
-  public void replace(ProjectData projectState) {
-    BulkRequest bulk =
-        new IndexRequest(projectState.getProject().getName(), indexName)
-            .add(new UpdateRequest<>(schema, projectState, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace project %s in index %s: %s",
-              projectState.getProject().getName(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray = getSortArray(ProjectField.NAME.getName());
-    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::projectFields), sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(Project.NameKey nameKey) {
-    return getDeleteRequest(nameKey);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.projects);
-  }
-
-  @Override
-  protected String getId(ProjectData projectState) {
-    return projectState.getProject().getName();
-  }
-
-  @Override
-  protected ProjectData fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    Project.NameKey nameKey =
-        Project.nameKey(source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
-    Optional<ProjectState> state = projectCache.get().get(nameKey);
-    if (!state.isPresent()) {
-      return null;
-    }
-    return state.get().toProjectData();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
deleted file mode 100644
index 19d9901..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.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.elasticsearch;
-
-public class ElasticQueryAdapter {
-  private static final String INDICES = "?allow_no_indices=false";
-
-  private final String searchFilteringName;
-  private final String exactFieldType;
-  private final String stringFieldType;
-  private final String indexProperty;
-  private final String rawFieldsKey;
-  private final String versionDiscoveryUrl;
-
-  ElasticQueryAdapter() {
-    this.versionDiscoveryUrl = "/%s*";
-    this.searchFilteringName = "_source";
-    this.exactFieldType = "keyword";
-    this.stringFieldType = "text";
-    this.indexProperty = "true";
-    this.rawFieldsKey = "_source";
-  }
-
-  public String searchFilteringName() {
-    return searchFilteringName;
-  }
-
-  String indicesExistParams() {
-    return INDICES;
-  }
-
-  String exactFieldType() {
-    return exactFieldType;
-  }
-
-  String stringFieldType() {
-    return stringFieldType;
-  }
-
-  String indexProperty() {
-    return indexProperty;
-  }
-
-  String rawFieldsKey() {
-    return rawFieldsKey;
-  }
-
-  String getVersionDiscoveryUrl(String name) {
-    return String.format(versionDiscoveryUrl, name);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
deleted file mode 100644
index 40ac603..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ /dev/null
@@ -1,163 +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.builders.BoolQueryBuilder;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.QueryBuilders;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.query.AndPredicate;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.IntegerRangePredicate;
-import com.google.gerrit.index.query.NotPredicate;
-import com.google.gerrit.index.query.OrPredicate;
-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.index.query.RegexPredicate;
-import com.google.gerrit.index.query.TimestampRangePredicate;
-import java.time.Instant;
-
-public class ElasticQueryBuilder {
-
-  <T> QueryBuilder toQueryBuilder(Predicate<T> p) throws QueryParseException {
-    if (p instanceof AndPredicate) {
-      return and(p);
-    } else if (p instanceof OrPredicate) {
-      return or(p);
-    } else if (p instanceof NotPredicate) {
-      return not(p);
-    } else if (p instanceof IndexPredicate) {
-      return fieldQuery((IndexPredicate<T>) p);
-    } else if (p instanceof PostFilterPredicate) {
-      return QueryBuilders.matchAllQuery();
-    } else {
-      throw new QueryParseException("cannot create query for index: " + p);
-    }
-  }
-
-  private <T> BoolQueryBuilder and(Predicate<T> p) throws QueryParseException {
-    BoolQueryBuilder b = QueryBuilders.boolQuery();
-    for (Predicate<T> c : p.getChildren()) {
-      b.must(toQueryBuilder(c));
-    }
-    return b;
-  }
-
-  private <T> BoolQueryBuilder or(Predicate<T> p) throws QueryParseException {
-    BoolQueryBuilder q = QueryBuilders.boolQuery();
-    for (Predicate<T> c : p.getChildren()) {
-      q.should(toQueryBuilder(c));
-    }
-    return q;
-  }
-
-  private <T> QueryBuilder not(Predicate<T> p) throws QueryParseException {
-    Predicate<T> n = p.getChild(0);
-    if (n instanceof TimestampRangePredicate) {
-      return notTimestamp((TimestampRangePredicate<T>) n);
-    }
-
-    // Lucene does not support negation, start with all and subtract.
-    BoolQueryBuilder q = QueryBuilders.boolQuery();
-    q.must(QueryBuilders.matchAllQuery());
-    q.mustNot(toQueryBuilder(n));
-    return q;
-  }
-
-  private <T> QueryBuilder fieldQuery(IndexPredicate<T> p) throws QueryParseException {
-    FieldType<?> type = p.getType();
-    FieldDef<?, ?> field = p.getField();
-    String name = field.getName();
-    String value = p.getValue();
-
-    if (type == FieldType.INTEGER) {
-      // QueryBuilder encodes integer fields as prefix coded bits,
-      // which elasticsearch's queryString can't handle.
-      // Create integer terms with string representations instead.
-      return QueryBuilders.termQuery(name, value);
-    } else if (type == FieldType.INTEGER_RANGE) {
-      return intRangeQuery(p);
-    } else if (type == FieldType.TIMESTAMP) {
-      return timestampQuery(p);
-    } else if (type == FieldType.EXACT) {
-      return exactQuery(p);
-    } else if (type == FieldType.PREFIX) {
-      return QueryBuilders.matchPhrasePrefixQuery(name, value);
-    } else if (type == FieldType.FULL_TEXT) {
-      return QueryBuilders.matchPhraseQuery(name, value);
-    } else {
-      throw FieldType.badFieldType(p.getType());
-    }
-  }
-
-  private <T> QueryBuilder intRangeQuery(IndexPredicate<T> p) throws QueryParseException {
-    if (p instanceof IntegerRangePredicate) {
-      IntegerRangePredicate<T> r = (IntegerRangePredicate<T>) p;
-      int minimum = r.getMinimumValue();
-      int maximum = r.getMaximumValue();
-      if (minimum == maximum) {
-        // Just fall back to a standard integer query.
-        return QueryBuilders.termQuery(p.getField().getName(), minimum);
-      }
-      return QueryBuilders.rangeQuery(p.getField().getName()).gte(minimum).lte(maximum);
-    }
-    throw new QueryParseException("not an integer range: " + p);
-  }
-
-  private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r) throws QueryParseException {
-    if (r.getMinTimestamp().getTime() == 0) {
-      return QueryBuilders.rangeQuery(r.getField().getName())
-          .gt(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
-    }
-    throw new QueryParseException("cannot negate: " + r);
-  }
-
-  private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
-    if (p instanceof TimestampRangePredicate) {
-      TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
-      if (r.getMaxTimestamp().getTime() == Long.MAX_VALUE) {
-        // The time range only has the start value, search from the start to the max supported value
-        // Long.MAX_VALUE
-        return QueryBuilders.rangeQuery(r.getField().getName())
-            .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
-      }
-      return QueryBuilders.rangeQuery(r.getField().getName())
-          .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()))
-          .lte(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
-    }
-    throw new QueryParseException("not a timestamp: " + p);
-  }
-
-  private <T> QueryBuilder exactQuery(IndexPredicate<T> p) {
-    String name = p.getField().getName();
-    String value = p.getValue();
-
-    if (!p.getField().isRepeatable() && value.isEmpty()) {
-      return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name));
-    } else if (p instanceof RegexPredicate) {
-      if (value.startsWith("^")) {
-        value = value.substring(1);
-      }
-      if (value.endsWith("$") && !value.endsWith("\\$") && !value.endsWith("\\\\$")) {
-        value = value.substring(0, value.length() - 1);
-      }
-      return QueryBuilders.regexpQuery(name + ".key", value);
-    } else {
-      return QueryBuilders.termQuery(name + ".key", value);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
deleted file mode 100644
index b41f365..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ /dev/null
@@ -1,157 +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.elasticsearch;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gson.JsonParser;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.apache.http.auth.AuthScope;
-import org.apache.http.auth.UsernamePasswordCredentials;
-import org.apache.http.client.CredentialsProvider;
-import org.apache.http.client.config.RequestConfig;
-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;
-
-@Singleton
-class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ElasticConfiguration cfg;
-
-  private volatile RestClient client;
-  private ElasticQueryAdapter adapter;
-
-  @Inject
-  ElasticRestClientProvider(ElasticConfiguration cfg) {
-    this.cfg = cfg;
-  }
-
-  public static LifecycleModule module() {
-    return new LifecycleModule() {
-      @Override
-      protected void configure() {
-        listener().to(ElasticRestClientProvider.class);
-      }
-    };
-  }
-
-  @Override
-  public RestClient get() {
-    if (client == null) {
-      synchronized (this) {
-        if (client == null) {
-          client = build();
-          ElasticVersion version = getVersion();
-          logger.atInfo().log("Elasticsearch integration version %s", version);
-          adapter = new ElasticQueryAdapter();
-        }
-      }
-    }
-    return client;
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public void stop() {
-    if (client != null) {
-      try {
-        client.close();
-      } catch (IOException e) {
-        // Ignore. We can't do anything about it.
-      }
-    }
-  }
-
-  ElasticQueryAdapter adapter() {
-    get(); // Make sure we're connected
-    return adapter;
-  }
-
-  public static class FailedToGetVersion extends ElasticException {
-    private static final long serialVersionUID = 1L;
-    private static final String MESSAGE = "Failed to get Elasticsearch version";
-
-    FailedToGetVersion(StatusLine status) {
-      super(String.format("%s: %d %s", MESSAGE, status.getStatusCode(), status.getReasonPhrase()));
-    }
-
-    FailedToGetVersion(Throwable cause) {
-      super(MESSAGE, cause);
-    }
-  }
-
-  private ElasticVersion getVersion() throws ElasticException {
-    try {
-      Response response = client.performRequest(new Request("GET", "/"));
-      StatusLine statusLine = response.getStatusLine();
-      if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
-        throw new FailedToGetVersion(statusLine);
-      }
-      String version =
-          new JsonParser()
-              .parse(AbstractElasticIndex.getContent(response))
-              .getAsJsonObject()
-              .get("version")
-              .getAsJsonObject()
-              .get("number")
-              .getAsString();
-      logger.atInfo().log("Connected to Elasticsearch version %s", version);
-      return ElasticVersion.forVersion(version);
-    } catch (IOException e) {
-      throw new FailedToGetVersion(e);
-    }
-  }
-
-  private RestClient build() {
-    RestClientBuilder builder = RestClient.builder(cfg.getHosts());
-    setConfiguredTimeouts(builder);
-    setConfiguredCredentialsIfAny(builder);
-    return builder.build();
-  }
-
-  private void setConfiguredTimeouts(RestClientBuilder builder) {
-    builder.setRequestConfigCallback(
-        (RequestConfig.Builder requestConfigBuilder) ->
-            requestConfigBuilder
-                .setConnectTimeout(cfg.connectTimeout)
-                .setSocketTimeout(cfg.socketTimeout));
-  }
-
-  private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
-    String username = cfg.username;
-    String password = cfg.password;
-    if (username != null && password != null) {
-      CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
-      credentialsProvider.setCredentials(
-          AuthScope.ANY, new UsernamePasswordCredentials(username, password));
-      builder.setHttpClientConfigCallback(
-          (HttpAsyncClientBuilder httpClientBuilder) ->
-              httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
deleted file mode 100644
index 7ec0566..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticSetting.java
+++ /dev/null
@@ -1,97 +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.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) {
-    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config);
-  }
-
-  static class Builder {
-    private final ImmutableMap.Builder<String, FieldProperties> fields =
-        new ImmutableMap.Builder<>();
-
-    SettingProperties build(ElasticConfiguration config) {
-      SettingProperties properties = new SettingProperties();
-      properties.analysis = fields.build();
-      properties.numberOfShards = config.getNumberOfShards();
-      properties.numberOfReplicas = config.numberOfReplicas;
-      properties.maxResultWindow = config.maxResultWindow;
-      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;
-    Integer maxResultWindow;
-  }
-
-  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/ElasticStoredValue.java b/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
deleted file mode 100644
index a02a715..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.collect.ImmutableList.toImmutableList;
-
-import com.google.gerrit.index.StoredValue;
-import com.google.gson.JsonElement;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.format.DateTimeFormatter;
-import java.util.stream.StreamSupport;
-
-/** Bridge to recover fields from the elastic index. */
-public class ElasticStoredValue implements StoredValue {
-  private final JsonElement field;
-
-  ElasticStoredValue(JsonElement field) {
-    this.field = field;
-  }
-
-  @Override
-  public String asString() {
-    return field.getAsString();
-  }
-
-  @Override
-  public Iterable<String> asStrings() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> f.getAsString())
-        .collect(toImmutableList());
-  }
-
-  @Override
-  public Integer asInteger() {
-    return field.getAsInt();
-  }
-
-  @Override
-  public Iterable<Integer> asIntegers() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> f.getAsInt())
-        .collect(toImmutableList());
-  }
-
-  @Override
-  public Long asLong() {
-    return field.getAsLong();
-  }
-
-  @Override
-  public Iterable<Long> asLongs() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> f.getAsLong())
-        .collect(toImmutableList());
-  }
-
-  @Override
-  public Timestamp asTimestamp() {
-    return Timestamp.from(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(field.getAsString())));
-  }
-
-  @Override
-  public byte[] asByteArray() {
-    return AbstractElasticIndex.decodeBase64(field.getAsString());
-  }
-
-  @Override
-  public Iterable<byte[]> asByteArrays() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> AbstractElasticIndex.decodeBase64(f.getAsString()))
-        .collect(toImmutableList());
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
deleted file mode 100644
index b5bf44b..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.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.elasticsearch;
-
-import com.google.common.base.Joiner;
-import java.util.regex.Pattern;
-
-public enum ElasticVersion {
-  V7_6("7.6.*"),
-  V7_7("7.7.*"),
-  V7_8("7.8.*");
-
-  private final String version;
-  private final Pattern pattern;
-
-  ElasticVersion(String version) {
-    this.version = version;
-    this.pattern = Pattern.compile(version);
-  }
-
-  public static class UnsupportedVersion extends ElasticException {
-    private static final long serialVersionUID = 1L;
-
-    UnsupportedVersion(String version) {
-      super(
-          String.format(
-              "Unsupported version: [%s]. Supported versions: %s", version, supportedVersions()));
-    }
-  }
-
-  /**
-   * Convert a version String to an ElasticVersion if supported.
-   *
-   * @param version for which to return an ElasticVersion
-   * @return the corresponding ElasticVersion if supported
-   */
-  public static ElasticVersion forVersion(String version) {
-    for (ElasticVersion value : ElasticVersion.values()) {
-      if (value.pattern.matcher(version).matches()) {
-        return value;
-      }
-    }
-    throw new UnsupportedVersion(version);
-  }
-
-  public static String supportedVersions() {
-    return Joiner.on(", ").join(ElasticVersion.values());
-  }
-
-  @Override
-  public String toString() {
-    return version;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
deleted file mode 100644
index a204919..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A Query that matches documents matching boolean combinations of other queries.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.BoolQueryBuilder.
- */
-public class BoolQueryBuilder extends QueryBuilder {
-
-  private final List<QueryBuilder> mustClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> mustNotClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> filterClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> shouldClauses = new ArrayList<>();
-
-  /**
-   * Adds a query that <b>must</b> appear in the matching documents and will contribute to scoring.
-   */
-  public BoolQueryBuilder must(QueryBuilder queryBuilder) {
-    mustClauses.add(queryBuilder);
-    return this;
-  }
-
-  /**
-   * Adds a query that <b>must not</b> appear in the matching documents and will not contribute to
-   * scoring.
-   */
-  public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) {
-    mustNotClauses.add(queryBuilder);
-    return this;
-  }
-
-  /**
-   * Adds a query that <i>should</i> appear in the matching documents. For a boolean query with no
-   * <tt>MUST</tt> clauses one or more <code>SHOULD</code> clauses must match a document for the
-   * BooleanQuery to match.
-   */
-  public BoolQueryBuilder should(QueryBuilder queryBuilder) {
-    shouldClauses.add(queryBuilder);
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("bool");
-    doXArrayContent("must", mustClauses, builder);
-    doXArrayContent("filter", filterClauses, builder);
-    doXArrayContent("must_not", mustNotClauses, builder);
-    doXArrayContent("should", shouldClauses, builder);
-    builder.endObject();
-  }
-
-  private void doXArrayContent(String field, List<QueryBuilder> clauses, XContentBuilder builder)
-      throws IOException {
-    if (clauses.isEmpty()) {
-      return;
-    }
-    if (clauses.size() == 1) {
-      builder.field(field);
-      clauses.get(0).toXContent(builder);
-    } else {
-      builder.startArray(field);
-      for (QueryBuilder clause : clauses) {
-        clause.toXContent(builder);
-      }
-      builder.endArray();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
deleted file mode 100644
index 1b058d7..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-
-/**
- * Constructs a query that only match on documents that the field has a value in them.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.ExistsQueryBuilder.
- */
-class ExistsQueryBuilder extends QueryBuilder {
-
-  private final String name;
-
-  ExistsQueryBuilder(String name) {
-    this.name = name;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("exists");
-    builder.field("field", name);
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
deleted file mode 100644
index a3b303c..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-
-/**
- * A query that matches on all documents.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.MatchAllQueryBuilder.
- */
-class MatchAllQueryBuilder extends QueryBuilder {
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("match_all");
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
deleted file mode 100644
index c0becd1..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-import java.util.Locale;
-
-/**
- * Match query is a query that analyzes the text and constructs a query as the result of the
- * analysis. It can construct different queries based on the type provided.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.MatchQueryBuilder.
- */
-class MatchQueryBuilder extends QueryBuilder {
-
-  enum Type {
-    /** The text is analyzed and used as a phrase query. */
-    MATCH_PHRASE,
-    /** The text is analyzed and used in a phrase query, with the last term acting as a prefix. */
-    MATCH_PHRASE_PREFIX;
-
-    @Override
-    public String toString() {
-      return name().toLowerCase(Locale.US);
-    }
-  }
-
-  private final String name;
-
-  private final Object text;
-
-  private Type type;
-
-  /** Constructs a new text query. */
-  MatchQueryBuilder(String name, Object text) {
-    this.name = name;
-    this.text = text;
-  }
-
-  /** Sets the type of the text query. */
-  MatchQueryBuilder type(Type type) {
-    this.type = type;
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject(type.toString()).field(name, text).endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
deleted file mode 100644
index d6f154e..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-
-/** A trimmed down version of org.elasticsearch.index.query.QueryBuilder. */
-public abstract class QueryBuilder {
-
-  protected QueryBuilder() {}
-
-  protected void toXContent(XContentBuilder builder) throws IOException {
-    builder.startObject();
-    doXContent(builder);
-    builder.endObject();
-  }
-
-  protected abstract void doXContent(XContentBuilder builder) throws IOException;
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
deleted file mode 100644
index 940146f..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-/**
- * A static factory for simple "import static" usage.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.QueryBuilders.
- */
-public abstract class QueryBuilders {
-
-  /** A query that match on all documents. */
-  public static MatchAllQueryBuilder matchAllQuery() {
-    return new MatchAllQueryBuilder();
-  }
-
-  /**
-   * Creates a text query with type "PHRASE" for the provided field name and text.
-   *
-   * @param name The field name.
-   * @param text The query text (to be analyzed).
-   */
-  public static MatchQueryBuilder matchPhraseQuery(String name, Object text) {
-    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE);
-  }
-
-  /**
-   * Creates a match query with type "PHRASE_PREFIX" for the provided field name and text.
-   *
-   * @param name The field name.
-   * @param text The query text (to be analyzed).
-   */
-  public static MatchQueryBuilder matchPhrasePrefixQuery(String name, Object text) {
-    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE_PREFIX);
-  }
-
-  /**
-   * A Query that matches documents containing a term.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  public static TermQueryBuilder termQuery(String name, String value) {
-    return new TermQueryBuilder(name, value);
-  }
-
-  /**
-   * A Query that matches documents containing a term.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  public static TermQueryBuilder termQuery(String name, int value) {
-    return new TermQueryBuilder(name, value);
-  }
-
-  /**
-   * A Query that matches documents within an range of terms.
-   *
-   * @param name The field name
-   */
-  public static RangeQueryBuilder rangeQuery(String name) {
-    return new RangeQueryBuilder(name);
-  }
-
-  /**
-   * A Query that matches documents containing terms with a specified regular expression.
-   *
-   * @param name The name of the field
-   * @param regexp The regular expression
-   */
-  public static RegexpQueryBuilder regexpQuery(String name, String regexp) {
-    return new RegexpQueryBuilder(name, regexp);
-  }
-
-  /** A Query that matches documents matching boolean combinations of other queries. */
-  public static BoolQueryBuilder boolQuery() {
-    return new BoolQueryBuilder();
-  }
-
-  /**
-   * A filter to filter only documents where a field exists in them.
-   *
-   * @param name The name of the field
-   */
-  public static ExistsQueryBuilder existsQuery(String name) {
-    return new ExistsQueryBuilder(name);
-  }
-
-  private QueryBuilders() {}
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
deleted file mode 100644
index 1cb5c82..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-
-/** A trimmed down and modified version of org.elasticsearch.action.support.QuerySourceBuilder. */
-class QuerySourceBuilder {
-
-  private final QueryBuilder queryBuilder;
-
-  QuerySourceBuilder(QueryBuilder queryBuilder) {
-    this.queryBuilder = queryBuilder;
-  }
-
-  void innerToXContent(XContentBuilder builder) throws IOException {
-    builder.field("query");
-    queryBuilder.toXContent(builder);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
deleted file mode 100644
index 32dbc0e..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that matches documents within an range of terms.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.RangeQueryBuilder.
- */
-public class RangeQueryBuilder extends QueryBuilder {
-
-  private final String name;
-  private Object from;
-  private Object to;
-  private boolean includeLower = true;
-  private boolean includeUpper = true;
-
-  /**
-   * A Query that matches documents within an range of terms.
-   *
-   * @param name The field name
-   */
-  RangeQueryBuilder(String name) {
-    this.name = name;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gt(Object from) {
-    this.from = from;
-    this.includeLower = false;
-    return this;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gte(Object from) {
-    this.from = from;
-    this.includeLower = true;
-    return this;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gte(int from) {
-    this.from = from;
-    this.includeLower = true;
-    return this;
-  }
-
-  /** The to part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder lte(Object to) {
-    this.to = to;
-    this.includeUpper = true;
-    return this;
-  }
-
-  /** The to part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder lte(int to) {
-    this.to = to;
-    this.includeUpper = true;
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("range");
-    builder.startObject(name);
-
-    builder.field("from", from);
-    builder.field("to", to);
-    builder.field("include_lower", includeLower);
-    builder.field("include_upper", includeUpper);
-
-    builder.endObject();
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
deleted file mode 100644
index b81ec20..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that does fuzzy matching for a specific value.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.RegexpQueryBuilder.
- */
-class RegexpQueryBuilder extends QueryBuilder {
-
-  private final String name;
-  private final String regexp;
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param regexp The regular expression
-   */
-  RegexpQueryBuilder(String name, String regexp) {
-    this.name = name;
-    this.regexp = regexp;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("regexp");
-    builder.startObject(name);
-
-    builder.field("value", regexp);
-    builder.field("flags_value", 65535);
-
-    builder.endObject();
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
deleted file mode 100644
index 35cbea9..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import com.google.gerrit.elasticsearch.ElasticQueryAdapter;
-import java.io.IOException;
-import java.util.List;
-
-/**
- * A search source builder allowing to easily build search source.
- *
- * <p>A trimmed down and modified version of org.elasticsearch.search.builder.SearchSourceBuilder.
- */
-public class SearchSourceBuilder {
-  private final ElasticQueryAdapter adapter;
-
-  private QuerySourceBuilder querySourceBuilder;
-
-  private int from = -1;
-
-  private int size = -1;
-
-  private List<String> fieldNames;
-
-  /** Constructs a new search source builder. */
-  public SearchSourceBuilder(ElasticQueryAdapter adapter) {
-    this.adapter = adapter;
-  }
-
-  /** Constructs a new search source builder with a search query. */
-  public SearchSourceBuilder query(QueryBuilder query) {
-    if (this.querySourceBuilder == null) {
-      this.querySourceBuilder = new QuerySourceBuilder(query);
-    }
-    return this;
-  }
-
-  /** From index to start the search from. Defaults to <tt>0</tt>. */
-  public SearchSourceBuilder from(int from) {
-    this.from = from;
-    return this;
-  }
-
-  /** The number of search hits to return. Defaults to <tt>10</tt>. */
-  public SearchSourceBuilder size(int size) {
-    this.size = size;
-    return this;
-  }
-
-  /**
-   * Sets the fields to load and return as part of the search request. If none are specified, the
-   * source of the document will be returned.
-   */
-  public SearchSourceBuilder fields(List<String> fields) {
-    this.fieldNames = fields;
-    return this;
-  }
-
-  @Override
-  public final String toString() {
-    try {
-      XContentBuilder builder = new XContentBuilder();
-      toXContent(builder);
-      return builder.string();
-    } catch (IOException ioe) {
-      return "";
-    }
-  }
-
-  private void toXContent(XContentBuilder builder) throws IOException {
-    builder.startObject();
-    innerToXContent(builder);
-    builder.endObject();
-  }
-
-  private void innerToXContent(XContentBuilder builder) throws IOException {
-    if (from != -1) {
-      builder.field("from", from);
-    }
-    if (size != -1) {
-      builder.field("size", size);
-    }
-
-    if (querySourceBuilder != null) {
-      querySourceBuilder.innerToXContent(builder);
-    }
-
-    if (fieldNames != null) {
-      if (fieldNames.size() == 1) {
-        builder.field(adapter.searchFilteringName(), fieldNames.get(0));
-      } else {
-        builder.startArray(adapter.searchFilteringName());
-        for (String fieldName : fieldNames) {
-          builder.value(fieldName);
-        }
-        builder.endArray();
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
deleted file mode 100644
index 2b407c6..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that matches documents containing a term.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.TermQueryBuilder.
- */
-class TermQueryBuilder extends QueryBuilder {
-
-  private final String name;
-
-  private final Object value;
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  TermQueryBuilder(String name, String value) {
-    this(name, (Object) value);
-  }
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  TermQueryBuilder(String name, int value) {
-    this(name, (Object) value);
-  }
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  private TermQueryBuilder(String name, Object value) {
-    this.name = name;
-    this.value = value;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("term");
-    builder.field(name, value);
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java b/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
deleted file mode 100644
index 9c44583..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.builders;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.time.format.DateTimeFormatter.ISO_INSTANT;
-
-import com.fasterxml.jackson.core.JsonEncoding;
-import com.fasterxml.jackson.core.JsonFactory;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.json.JsonReadFeature;
-import com.fasterxml.jackson.core.json.JsonWriteFeature;
-import java.io.ByteArrayOutputStream;
-import java.io.Closeable;
-import java.io.IOException;
-import java.util.Date;
-
-/** A trimmed down and modified version of org.elasticsearch.common.xcontent.XContentBuilder. */
-public final class XContentBuilder implements Closeable {
-
-  private final JsonGenerator generator;
-
-  private final ByteArrayOutputStream bos = new ByteArrayOutputStream();
-
-  /**
-   * Constructs a new builder. Make sure to call {@link #close()} when the builder is done with.
-   * Inspired from org.elasticsearch.common.xcontent.json.JsonXContent static block.
-   */
-  public XContentBuilder() throws IOException {
-    this.generator =
-        JsonFactory.builder()
-            .configure(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES, true)
-            .configure(JsonWriteFeature.QUOTE_FIELD_NAMES, true)
-            .configure(JsonReadFeature.ALLOW_JAVA_COMMENTS, true)
-            .configure(JsonFactory.Feature.FAIL_ON_SYMBOL_HASH_OVERFLOW, false)
-            .build()
-            .createGenerator(bos, JsonEncoding.UTF8);
-  }
-
-  public XContentBuilder startObject(String name) throws IOException {
-    field(name);
-    startObject();
-    return this;
-  }
-
-  public XContentBuilder startObject() throws IOException {
-    generator.writeStartObject();
-    return this;
-  }
-
-  public XContentBuilder endObject() throws IOException {
-    generator.writeEndObject();
-    return this;
-  }
-
-  public void startArray(String name) throws IOException {
-    field(name);
-    startArray();
-  }
-
-  private void startArray() throws IOException {
-    generator.writeStartArray();
-  }
-
-  public void endArray() throws IOException {
-    generator.writeEndArray();
-  }
-
-  public XContentBuilder field(String name) throws IOException {
-    generator.writeFieldName(name);
-    return this;
-  }
-
-  public XContentBuilder field(String name, String value) throws IOException {
-    field(name);
-    generator.writeString(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, int value) throws IOException {
-    field(name);
-    generator.writeNumber(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, Iterable<?> value) throws IOException {
-    startArray(name);
-    for (Object o : value) {
-      value(o);
-    }
-    endArray();
-    return this;
-  }
-
-  public XContentBuilder field(String name, Object value) throws IOException {
-    field(name);
-    writeValue(value);
-    return this;
-  }
-
-  public XContentBuilder value(Object value) throws IOException {
-    writeValue(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, boolean value) throws IOException {
-    field(name);
-    generator.writeBoolean(value);
-    return this;
-  }
-
-  public XContentBuilder value(String value) throws IOException {
-    generator.writeString(value);
-    return this;
-  }
-
-  @Override
-  public void close() {
-    try {
-      generator.close();
-    } catch (IOException e) {
-      // ignore
-    }
-  }
-
-  /** Returns a string representation of the builder (only applicable for text based xcontent). */
-  public String string() {
-    close();
-    byte[] bytesArray = bos.toByteArray();
-    return new String(bytesArray, UTF_8);
-  }
-
-  private void writeValue(Object value) throws IOException {
-    if (value == null) {
-      generator.writeNull();
-      return;
-    }
-    Class<?> type = value.getClass();
-    if (type == String.class) {
-      generator.writeString((String) value);
-    } else if (type == Integer.class) {
-      generator.writeNumber(((Integer) value));
-    } else if (type == byte[].class) {
-      generator.writeBinary((byte[]) value);
-    } else if (value instanceof Date) {
-      generator.writeString(ISO_INSTANT.format(((Date) value).toInstant()));
-    } else {
-      // if this is a "value" object, like enum, DistanceUnit, ..., just toString it
-      // yea, it can be misleading when toString a Java class, but really, jackson should be used in
-      // that case
-      generator.writeString(value.toString());
-      // throw new ElasticsearchIllegalArgumentException("type not supported for generic value
-      // conversion: " + type);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java b/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
deleted file mode 100644
index 16b821c..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
+++ /dev/null
@@ -1,41 +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.elasticsearch.bulk;
-
-import com.google.gson.JsonObject;
-
-abstract class ActionRequest extends BulkRequest {
-
-  private final String action;
-  private final String id;
-  private final String index;
-
-  protected ActionRequest(String action, String id, String index) {
-    this.action = action;
-    this.id = id;
-    this.index = index;
-  }
-
-  @Override
-  protected String getRequest() {
-    JsonObject properties = new JsonObject();
-    properties.addProperty("_id", id);
-    properties.addProperty("_index", index);
-
-    JsonObject jsonAction = new JsonObject();
-    jsonAction.add(action, properties);
-    return jsonAction.toString() + System.lineSeparator();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java b/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
deleted file mode 100644
index be5ad8d..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
+++ /dev/null
@@ -1,43 +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.elasticsearch.bulk;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public abstract class BulkRequest {
-
-  private final List<BulkRequest> requests = new ArrayList<>();
-
-  protected BulkRequest() {
-    add(this);
-  }
-
-  public BulkRequest add(BulkRequest request) {
-    requests.add(request);
-    return this;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder builder = new StringBuilder();
-    for (BulkRequest request : requests) {
-      builder.append(request.getRequest());
-    }
-    return builder.toString();
-  }
-
-  protected abstract String getRequest();
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java b/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
deleted file mode 100644
index 6451b0f..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
+++ /dev/null
@@ -1,22 +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.elasticsearch.bulk;
-
-public class DeleteRequest extends ActionRequest {
-
-  public DeleteRequest(String id, String index) {
-    super("delete", id, index);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java b/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
deleted file mode 100644
index d90b80f..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
+++ /dev/null
@@ -1,22 +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.elasticsearch.bulk;
-
-public class IndexRequest extends ActionRequest {
-
-  public IndexRequest(String id, String index) {
-    super("index", id, index);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
deleted file mode 100644
index 196b8d6..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.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.elasticsearch.bulk;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.elasticsearch.builders.XContentBuilder;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.Schema.Values;
-import java.io.IOException;
-
-public class UpdateRequest<V> extends BulkRequest {
-
-  private final Schema<V> schema;
-  private final V v;
-  private final ImmutableSet<String> skipFields;
-
-  public UpdateRequest(Schema<V> schema, V v, ImmutableSet<String> skipFields) {
-    this.schema = schema;
-    this.v = v;
-    this.skipFields = skipFields;
-  }
-
-  @Override
-  protected String getRequest() {
-    try (XContentBuilder closeable = new XContentBuilder()) {
-      XContentBuilder builder = closeable.startObject();
-      for (Values<V> values : schema.buildFields(v, skipFields)) {
-        String name = values.getField().getName();
-        if (values.getField().isRepeatable()) {
-          builder.field(name, Streams.stream(values.getValues()).collect(toList()));
-        } else {
-          Object element = Iterables.getOnlyElement(values.getValues(), "");
-          if (shouldAddElement(element)) {
-            builder.field(name, element);
-          }
-        }
-      }
-      return builder.endObject().string() + System.lineSeparator();
-    } catch (IOException e) {
-      return e.toString();
-    }
-  }
-
-  private boolean shouldAddElement(Object element) {
-    return !(element instanceof String) || !((String) element).isEmpty();
-  }
-}
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
index 69a234a..8ae0a5d 100644
--- a/java/com/google/gerrit/entities/AccessSection.java
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -18,12 +18,14 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Consumer;
+import java.util.regex.Pattern;
 
 /** Portion of a {@link Project} describing access rules. */
 @AutoValue
@@ -42,6 +44,20 @@
   /** Name of the access section. It could be a ref pattern or something else. */
   public abstract String getName();
 
+  /**
+   * A compiled regular expression in case {@link #getName()} is a regular expression. This is
+   * memoized to save callers from compiling patterns for every use.
+   */
+  @Memoized
+  public Optional<Pattern> getNamePattern() {
+    if (isValidRefSectionName(getName())
+        && getName().startsWith(REGEX_PREFIX)
+        && !getName().contains("${")) {
+      return Optional.of(Pattern.compile(getName()));
+    }
+    return Optional.empty();
+  }
+
   public abstract ImmutableList<Permission> getPermissions();
 
   public static AccessSection create(String name) {
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 18fcef3..699acc0 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -22,7 +22,7 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /**
@@ -46,6 +46,10 @@
  */
 @AutoValue
 public abstract class Account {
+
+  /** Placeholder for indicating an account-id that does not correspond to any local account */
+  public static final Id UNKNOWN_ACCOUNT_ID = id(0);
+
   public static Id id(int id) {
     return new AutoValue_Account_Id(id);
   }
@@ -58,6 +62,7 @@
       return Optional.ofNullable(Ints.tryParse(str)).map(Account::id);
     }
 
+    @Nullable
     public static Id fromRef(String name) {
       if (name == null) {
         return null;
@@ -78,11 +83,13 @@
      * @param name a ref name with the following syntax: {@code "34/1234..."}. We assume that the
      *     caller has trimmed any prefix.
      */
+    @Nullable
     public static Id fromRefPart(String name) {
       Integer id = RefNames.parseShardedRefPart(name);
       return id != null ? Account.id(id) : null;
     }
 
+    @Nullable
     public static Id parseAfterShardedRefPart(String name) {
       Integer id = RefNames.parseAfterShardedRefPart(name);
       return id != null ? Account.id(id) : null;
@@ -98,6 +105,7 @@
      * @param name ref name
      * @return account ID, or null if not numeric.
      */
+    @Nullable
     public static Id fromRefSuffix(String name) {
       Integer id = RefNames.parseRefSuffix(name);
       return id != null ? Account.id(id) : null;
@@ -123,7 +131,7 @@
   public abstract Id id();
 
   /** Date and time the user registered with the review server. */
-  public abstract Timestamp registeredOn();
+  public abstract Instant registeredOn();
 
   /** Full name of the user ("Given-name Surname" style). */
   @Nullable
@@ -157,7 +165,7 @@
    * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
    * @param registeredOn when the account was registered.
    */
-  public static Account.Builder builder(Account.Id newId, Timestamp registeredOn) {
+  public static Account.Builder builder(Account.Id newId, Instant registeredOn) {
     return new AutoValue_Account.Builder()
         .setInactive(false)
         .setId(newId)
@@ -230,9 +238,9 @@
 
     abstract Builder setId(Id id);
 
-    public abstract Timestamp registeredOn();
+    public abstract Instant registeredOn();
 
-    abstract Builder setRegisteredOn(Timestamp registeredOn);
+    abstract Builder setRegisteredOn(Instant registeredOn);
 
     @Nullable
     public abstract String fullName();
diff --git a/java/com/google/gerrit/entities/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java
index 001a544..b5c97da 100644
--- a/java/com/google/gerrit/entities/AccountGroup.java
+++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 
 public final class AccountGroup {
   public static NameKey nameKey(String n) {
@@ -65,6 +66,7 @@
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a ref-name. */
+    @Nullable
     public static UUID fromRef(String ref) {
       if (ref == null) {
         return null;
@@ -81,6 +83,7 @@
      * @param refPart a ref name with the following syntax: {@code "12/1234..."}. We assume that the
      *     caller has trimmed any prefix.
      */
+    @Nullable
     public static UUID fromRefPart(String refPart) {
       String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
       return uuid != null ? AccountGroup.uuid(uuid) : null;
diff --git a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
index 17ddf51..0ef51e5 100644
--- a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
@@ -33,13 +33,13 @@
 
     public abstract Builder addedBy(Account.Id addedBy);
 
-    public abstract Builder addedOn(Timestamp addedOn);
+    public abstract Builder addedOn(Instant addedOn);
 
     abstract Builder removedBy(Account.Id removedBy);
 
-    abstract Builder removedOn(Timestamp removedOn);
+    abstract Builder removedOn(Instant removedOn);
 
-    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+    public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
 
@@ -52,11 +52,11 @@
 
   public abstract Account.Id addedBy();
 
-  public abstract Timestamp addedOn();
+  public abstract Instant addedOn();
 
   public abstract Optional<Account.Id> removedBy();
 
-  public abstract Optional<Timestamp> removedOn();
+  public abstract Optional<Instant> removedOn();
 
   public abstract Builder toBuilder();
 
diff --git a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
index 4d191b8..913956e 100644
--- a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Membership of an {@link Account} in an {@link AccountGroup}. */
@@ -35,15 +35,15 @@
 
     abstract Account.Id addedBy();
 
-    public abstract Builder addedOn(Timestamp addedOn);
+    public abstract Builder addedOn(Instant addedOn);
 
-    abstract Timestamp addedOn();
+    abstract Instant addedOn();
 
     abstract Builder removedBy(Account.Id removedBy);
 
-    abstract Builder removedOn(Timestamp removedOn);
+    abstract Builder removedOn(Instant removedOn);
 
-    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+    public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
 
@@ -60,11 +60,11 @@
 
   public abstract Account.Id addedBy();
 
-  public abstract Timestamp addedOn();
+  public abstract Instant addedOn();
 
   public abstract Optional<Account.Id> removedBy();
 
-  public abstract Optional<Timestamp> removedOn();
+  public abstract Optional<Instant> removedOn();
 
   public abstract Builder toBuilder();
 
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
index 5d63476..eb1da46 100644
--- a/java/com/google/gerrit/entities/Address.java
+++ b/java/com/google/gerrit/entities/Address.java
@@ -46,6 +46,7 @@
     throw new IllegalArgumentException("Invalid email address: " + in);
   }
 
+  @Nullable
   public static Address tryParse(String in) {
     try {
       return parse(in);
diff --git a/java/com/google/gerrit/entities/BooleanProjectConfig.java b/java/com/google/gerrit/entities/BooleanProjectConfig.java
index 5201f6d..605c40c 100644
--- a/java/com/google/gerrit/entities/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/entities/BooleanProjectConfig.java
@@ -41,7 +41,9 @@
   ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
   MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
   REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit"),
-  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault");
+  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault"),
+  SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS(
+      "reviewer", "skipAddingAuthorAndCommitterAsReviewers");
 
   // Git config
   private final String section;
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 8740235..be4a1cf 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
@@ -69,7 +70,7 @@
   public abstract AccountsSection getAccountsSection();
 
   /** Returns a map of {@link AccessSection}s keyed by their name. */
-  public abstract ImmutableMap<String, AccessSection> getAccessSections();
+  public abstract ImmutableSortedMap<String, AccessSection> getAccessSections();
 
   /** Returns the {@link AccessSection} with to the given name. */
   public Optional<AccessSection> getAccessSection(String refName) {
@@ -226,7 +227,7 @@
       try {
         parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
       } catch (ConfigInvalidException e) {
-        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+        logger.atInfo().withCause(e).log("Config for %s not parsable", configFileName);
       }
       return this;
     }
@@ -235,7 +236,7 @@
 
     protected abstract ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupsBuilder();
 
-    protected abstract ImmutableMap.Builder<String, AccessSection> accessSectionsBuilder();
+    protected abstract ImmutableSortedMap.Builder<String, AccessSection> accessSectionsBuilder();
 
     protected abstract ImmutableMap.Builder<String, ContributorAgreement>
         contributorAgreementsBuilder();
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index d1826bc..55220f3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 
 import com.google.auto.value.AutoValue;
@@ -24,7 +23,7 @@
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import com.google.gson.annotations.SerializedName;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 
@@ -109,18 +108,6 @@
     /**
      * Parse a Change.Id out of a string representation.
      *
-     * @deprecated use {@link #tryParse(String)} instead.
-     */
-    @Deprecated
-    public static Id parse(String str) {
-      Integer id = Ints.tryParse(str);
-      checkArgument(id != null, "invalid change ID: %s", str);
-      return Change.id(id);
-    }
-
-    /**
-     * Parse a Change.Id out of a string representation.
-     *
      * @param str the string to parse
      * @return Optional containing the Change.Id, or {@code Optional.empty()} if str does not
      *     represent a valid Change.Id.
@@ -130,6 +117,7 @@
       return id != null ? Optional.of(Change.id(id)) : Optional.empty();
     }
 
+    @Nullable
     public static Id fromRef(String ref) {
       if (RefNames.isRefsEdit(ref)) {
         return fromEditRefPart(ref);
@@ -147,6 +135,7 @@
       return null;
     }
 
+    @Nullable
     public static Id fromAllUsersRef(String ref) {
       if (ref == null) {
         return null;
@@ -182,6 +171,7 @@
       return true;
     }
 
+    @Nullable
     public static Id fromEditRefPart(String ref) {
       int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) + RefNames.EDIT_PREFIX.length();
       int endChangeId = nextNonDigit(ref, startChangeId);
@@ -192,6 +182,7 @@
       return null;
     }
 
+    @Nullable
     public static Id fromRefPart(String ref) {
       Integer id = RefNames.parseShardedRefPart(ref);
       return id != null ? Change.id(id) : null;
@@ -417,6 +408,7 @@
       return changeStatus;
     }
 
+    @Nullable
     public static Status forCode(char c) {
       for (Status s : Status.values()) {
         if (s.code == c) {
@@ -427,6 +419,7 @@
       return null;
     }
 
+    @Nullable
     public static Status forChangeStatus(ChangeStatus cs) {
       for (Status s : Status.values()) {
         if (s.changeStatus == cs) {
@@ -438,37 +431,37 @@
   }
 
   /** Locally assigned unique identifier of the change */
-  protected Id changeId;
+  private Id changeId;
 
   /** Globally assigned unique identifier of the change */
-  protected Key changeKey;
+  private Key changeKey;
 
   /** When this change was first introduced into the database. */
-  protected Timestamp createdOn;
+  private Instant createdOn;
 
   /**
    * When was a meaningful modification last made to this record's data
    *
    * <p>Note, this update timestamp includes its children.
    */
-  protected Timestamp lastUpdatedOn;
+  private Instant lastUpdatedOn;
 
-  protected Account.Id owner;
+  private Account.Id owner;
 
   /** The branch (and project) this change merges into. */
-  protected BranchNameKey dest;
+  private BranchNameKey dest;
 
   /** Current state code; see {@link Status}. */
-  protected char status;
+  private char status;
 
   /** The current patch set. */
-  protected int currentPatchSetId;
+  private int currentPatchSetId;
 
   /** Subject from the current patch set. */
-  protected String subject;
+  private String subject;
 
   /** Topic name assigned by the user, if any. */
-  @Nullable protected String topic;
+  @Nullable private String topic;
 
   /**
    * First line of first patch set's commit message.
@@ -476,40 +469,36 @@
    * <p>Unlike {@link #subject}, this string does not change if future patch sets change the first
    * line.
    */
-  @Nullable protected String originalSubject;
+  @Nullable private String originalSubject;
 
   /**
    * Unique id for the changes submitted together assigned during merging. Only set if the status is
    * MERGED.
    */
-  @Nullable protected String submissionId;
+  @Nullable private String submissionId;
 
   /** Allows assigning a change to a user. */
-  @Nullable protected Account.Id assignee;
+  @Nullable private Account.Id assignee;
 
   /** Whether the change is private. */
-  protected boolean isPrivate;
+  private boolean isPrivate;
 
   /** Whether the change is work in progress. */
-  protected boolean workInProgress;
+  private boolean workInProgress;
 
   /** Whether the change has started review. */
-  protected boolean reviewStarted;
+  private boolean reviewStarted;
 
   /** References a change that this change reverts. */
-  @Nullable protected Id revertOf;
+  @Nullable private Id revertOf;
 
   /** References the source change and patchset that this change was cherry-picked from. */
-  @Nullable protected PatchSet.Id cherryPickOf;
+  @Nullable private PatchSet.Id cherryPickOf;
 
-  protected Change() {}
+  Change() {}
 
   public Change(
-      Change.Key newKey,
-      Change.Id newId,
-      Account.Id ownedBy,
-      BranchNameKey forBranch,
-      Timestamp ts) {
+      Change.Key newKey, Change.Id newId, Account.Id ownedBy, BranchNameKey forBranch, Instant ts) {
     changeKey = newKey;
     changeId = newId;
     createdOn = ts;
@@ -567,19 +556,19 @@
     assignee = a;
   }
 
-  public Timestamp getCreatedOn() {
+  public Instant getCreatedOn() {
     return createdOn;
   }
 
-  public void setCreatedOn(Timestamp ts) {
+  public void setCreatedOn(Instant ts) {
     createdOn = ts;
   }
 
-  public Timestamp getLastUpdatedOn() {
+  public Instant getLastUpdatedOn() {
     return lastUpdatedOn;
   }
 
-  public void setLastUpdatedOn(Timestamp now) {
+  public void setLastUpdatedOn(Instant now) {
     lastUpdatedOn = now;
   }
 
@@ -616,6 +605,7 @@
   }
 
   /** Get the id of the most current {@link PatchSet} in this change. */
+  @Nullable
   public PatchSet.Id currentPatchSetId() {
     if (currentPatchSetId > 0) {
       return PatchSet.id(changeId, currentPatchSetId);
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index cb56c31..609b54c 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -40,40 +40,40 @@
     public abstract String uuid();
   }
 
-  protected Key key;
+  private Key key;
 
   /** Who wrote this comment; null if it was written by the Gerrit system. */
-  @Nullable protected Account.Id author;
+  @Nullable private Account.Id author;
 
   /** When this comment was drafted. */
-  protected Timestamp writtenOn;
+  private Instant writtenOn;
 
   /**
    * The text left by the user or Gerrit system in template form, that is free of Gerrit User
    * Identifiable Information and can be persisted in data storage.
    */
-  @Nullable protected String message;
+  @Nullable private String message;
 
   /** Which patchset (if any) was this message generated from? */
-  @Nullable protected PatchSet.Id patchset;
+  @Nullable private PatchSet.Id patchset;
 
   /** Tag associated with change message */
-  @Nullable protected String tag;
+  @Nullable private String tag;
 
   /** Real user that added this message on behalf of the user recorded in {@link #author}. */
-  @Nullable protected Account.Id realAuthor;
+  @Nullable private Account.Id realAuthor;
 
-  protected ChangeMessage() {}
+  private ChangeMessage() {}
 
   public static ChangeMessage create(
-      final ChangeMessage.Key k, @Nullable Account.Id a, Timestamp wo, @Nullable PatchSet.Id psid) {
+      final ChangeMessage.Key k, @Nullable Account.Id a, Instant wo, @Nullable PatchSet.Id psid) {
     return create(k, a, wo, psid, /*messageTemplate=*/ null, /*realAuthor=*/ null, /*tag=*/ null);
   }
 
   public static ChangeMessage create(
       final ChangeMessage.Key k,
       @Nullable Account.Id a,
-      Timestamp wo,
+      Instant wo,
       @Nullable PatchSet.Id psid,
       @Nullable String messageTemplate,
       @Nullable Account.Id realAuthor,
@@ -103,7 +103,7 @@
     return realAuthor != null ? realAuthor : getAuthor();
   }
 
-  public Timestamp getWrittenOn() {
+  public Instant getWrittenOn() {
     return writtenOn;
   }
 
diff --git a/java/com/google/gerrit/entities/ChangeSizeBucket.java b/java/com/google/gerrit/entities/ChangeSizeBucket.java
new file mode 100644
index 0000000..4d01ebd
--- /dev/null
+++ b/java/com/google/gerrit/entities/ChangeSizeBucket.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.entities;
+
+/**
+ * A human-readable change size bucket.
+ *
+ * <p>Should be kept in sync with
+ * polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+ */
+public class ChangeSizeBucket {
+
+  /**
+   * The upper bounds for the different size buckets.
+   *
+   * <p>Same as gr-change-list-item.ts::ChangeSize.
+   */
+  private enum BucketThresholds {
+    XS(10),
+    SMALL(50),
+    MEDIUM(250),
+    LARGE(1000);
+
+    public final long delta;
+
+    BucketThresholds(long delta) {
+      this.delta = delta;
+    }
+  }
+
+  /**
+   * Gets the correlative size bucket for the given change delta.
+   *
+   * <p>Same as gr-change-list-item.ts::computeChangeSize().
+   *
+   * @param delta the total number of changed lines (additions+deletions) of the change.
+   * @return a short human-readable size bucket.
+   */
+  public static String getChangeSizeBucket(long delta) {
+    if (delta == 0) {
+      return "NoOp";
+    } else if (delta < BucketThresholds.XS.delta) {
+      return "XS";
+    } else if (delta < BucketThresholds.SMALL.delta) {
+      return "S";
+    } else if (delta < BucketThresholds.MEDIUM.delta) {
+      return "M";
+    } else if (delta < BucketThresholds.LARGE.delta) {
+      return "L";
+    }
+    return "XL";
+  }
+}
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 37b8620..e1e143c 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -18,6 +18,7 @@
 import com.google.common.base.MoreObjects.ToStringHelper;
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Comparator;
 import java.util.Objects;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -48,6 +49,7 @@
       return code;
     }
 
+    @Nullable
     public static Status forCode(char c) {
       for (Status s : Status.values()) {
         if (s.code == c) {
@@ -88,7 +90,7 @@
         Key k = (Key) o;
         return Objects.equals(uuid, k.uuid)
             && Objects.equals(filename, k.filename)
-            && Objects.equals(patchSetId, k.patchSetId);
+            && patchSetId == k.patchSetId;
       }
       return false;
     }
@@ -113,7 +115,7 @@
     @Override
     public boolean equals(Object o) {
       if (o instanceof Identity) {
-        return Objects.equals(id, ((Identity) o).id);
+        return id == ((Identity) o).id;
       }
       return false;
     }
@@ -180,10 +182,10 @@
     public boolean equals(Object o) {
       if (o instanceof Range) {
         Range r = (Range) o;
-        return Objects.equals(startLine, r.startLine)
-            && Objects.equals(startChar, r.startChar)
-            && Objects.equals(endLine, r.endLine)
-            && Objects.equals(endChar, r.endChar);
+        return startLine == r.startLine
+            && startChar == r.startChar
+            && endLine == r.endLine
+            && endChar == r.endChar;
       }
       return false;
     }
@@ -215,7 +217,10 @@
 
   public Identity author;
   protected Identity realAuthor;
+
+  // TODO(issue-15525): Migrate this field from Timestamp to Instant
   public Timestamp writtenOn;
+
   public short side;
   public String message;
   public String parentUuid;
@@ -233,13 +238,7 @@
   public String serverId;
 
   public Comment(Comment c) {
-    this(
-        new Key(c.key),
-        c.author.getId(),
-        new Timestamp(c.writtenOn.getTime()),
-        c.side,
-        c.message,
-        c.serverId);
+    this(new Key(c.key), c.author.getId(), c.writtenOn.toInstant(), c.side, c.message, c.serverId);
     this.lineNbr = c.lineNbr;
     this.realAuthor = c.realAuthor;
     this.parentUuid = c.parentUuid;
@@ -249,24 +248,23 @@
   }
 
   public Comment(
-      Key key,
-      Account.Id author,
-      Timestamp writtenOn,
-      short side,
-      String message,
-      String serverId) {
+      Key key, Account.Id author, Instant writtenOn, short side, String message, String serverId) {
     this.key = key;
     this.author = new Comment.Identity(author);
     this.realAuthor = this.author;
-    this.writtenOn = writtenOn;
+    this.writtenOn = Timestamp.from(writtenOn);
     this.side = side;
     this.message = message;
     this.serverId = serverId;
   }
 
+  public void setWrittenOn(Instant writtenOn) {
+    this.writtenOn = Timestamp.from(writtenOn);
+  }
+
   public void setLineNbrAndRange(
       Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
-    this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
+    this.lineNbr = range != null ? range.endLine : lineNbr != null ? lineNbr : 0;
     if (range != null) {
       this.range = new Comment.Range(range);
     }
diff --git a/java/com/google/gerrit/entities/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
index bf5a644..e43b6a3 100644
--- a/java/com/google/gerrit/entities/EmailHeader.java
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -19,7 +19,9 @@
 import com.google.common.base.MoreObjects;
 import java.io.IOException;
 import java.io.Writer;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -127,13 +129,13 @@
   }
 
   public static class Date extends EmailHeader {
-    private final java.util.Date value;
+    private final Instant value;
 
-    public Date(java.util.Date v) {
+    public Date(Instant v) {
       value = v;
     }
 
-    public java.util.Date getDate() {
+    public Instant getDate() {
       return value;
     }
 
@@ -144,10 +146,12 @@
 
     @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));
+      // Mon, 1 Jun 2009 10:49:44 +0000
+      w.write(
+          DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss Z")
+              .withLocale(Locale.US)
+              .withZone(ZoneId.of("UTC"))
+              .format(value));
     }
 
     @Override
diff --git a/java/com/google/gerrit/entities/GroupDescription.java b/java/com/google/gerrit/entities/GroupDescription.java
index 7054bed..666e8f6 100644
--- a/java/com/google/gerrit/entities/GroupDescription.java
+++ b/java/com/google/gerrit/entities/GroupDescription.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.gerrit.common.Nullable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Set;
 
 /** Group methods exposed by the GroupBackend. */
@@ -55,7 +55,7 @@
 
     boolean isVisibleToAll();
 
-    Timestamp getCreatedOn();
+    Instant getCreatedOn();
 
     Set<Account.Id> getMembers();
 
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 50bee8d..d287fa0 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -33,7 +33,7 @@
   public HumanComment(
       Key key,
       Account.Id author,
-      Timestamp writtenOn,
+      Instant writtenOn,
       short side,
       String message,
       String serverId,
diff --git a/java/com/google/gerrit/entities/InternalGroup.java b/java/com/google/gerrit/entities/InternalGroup.java
index ebfa36a..43c3af3 100644
--- a/java/com/google/gerrit/entities/InternalGroup.java
+++ b/java/com/google/gerrit/entities/InternalGroup.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import java.io.Serializable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
@@ -42,7 +42,7 @@
 
   public abstract AccountGroup.UUID getGroupUUID();
 
-  public abstract Timestamp getCreatedOn();
+  public abstract Instant getCreatedOn();
 
   public abstract ImmutableSet<Account.Id> getMembers();
 
@@ -71,7 +71,7 @@
 
     public abstract Builder setGroupUUID(AccountGroup.UUID groupUuid);
 
-    public abstract Builder setCreatedOn(Timestamp createdOn);
+    public abstract Builder setCreatedOn(Instant createdOn);
 
     public abstract Builder setMembers(ImmutableSet<Account.Id> members);
 
diff --git a/java/com/google/gerrit/entities/KeyUtil.java b/java/com/google/gerrit/entities/KeyUtil.java
index 40fb757..0f14cd9 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.entities;
 
-import java.io.UnsupportedEncodingException;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import java.util.Arrays;
 
 public class KeyUtil {
@@ -48,14 +49,8 @@
     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);
-    }
-
+  public static String encode(final String key) {
+    final byte[] b = key.getBytes(UTF_8);
     final StringBuilder r = new StringBuilder(b.length);
     for (int i = 0; i < b.length; i++) {
       final int c = b[i] & 0xff;
@@ -71,20 +66,20 @@
     return r.toString();
   }
 
-  public static String decode(final String e) {
-    if (e.indexOf('%') < 0) {
-      return e.replace('+', ' ');
+  public static String decode(final String key) {
+    if (key.indexOf('%') < 0) {
+      return key.replace('+', ' ');
     }
 
-    final byte[] b = new byte[e.length()];
+    final byte[] b = new byte[key.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)];
+      for (int i = 0; i < key.length(); ) {
+        final char c = key.charAt(i);
+        if (c == '%' && i + 2 < key.length()) {
+          final int v = (hexb[key.charAt(i + 1)] << 4) | hexb[key.charAt(i + 2)];
           if (v < 0) {
-            throw new IllegalArgumentException(e.substring(i, i + 3));
+            throw new IllegalArgumentException(key.substring(i, i + 3));
           }
           b[bPtr++] = (byte) v;
           i += 3;
@@ -97,12 +92,8 @@
         }
       }
     } catch (ArrayIndexOutOfBoundsException err) {
-      throw new IllegalArgumentException("Bad encoding" + e, err);
+      throw new IllegalArgumentException("Bad encoding" + key, err);
     }
-    try {
-      return new String(b, 0, bPtr, "UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
-    }
+    return new String(b, 0, bPtr, UTF_8);
   }
 }
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
index f361741..d49ab0f 100644
--- a/java/com/google/gerrit/entities/LabelFunction.java
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRecord.Label;
 import java.util.Collections;
@@ -48,6 +49,16 @@
     ALL = Collections.unmodifiableMap(all);
   }
 
+  public static final Map<String, LabelFunction> ALL_NON_DEPRECATED;
+
+  static {
+    Map<String, LabelFunction> allNonDeprecated = new LinkedHashMap<>();
+    for (LabelFunction f : ImmutableSet.of(NO_BLOCK, NO_OP, PATCH_SET_LOCK)) {
+      allNonDeprecated.put(f.getFunctionName(), f);
+    }
+    ALL_NON_DEPRECATED = Collections.unmodifiableMap(allNonDeprecated);
+  }
+
   public static Optional<LabelFunction> parse(@Nullable String str) {
     return Optional.ofNullable(ALL.get(str));
   }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index d254752..f009872 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
 
@@ -30,15 +29,6 @@
 public abstract class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
-  public static final boolean DEF_COPY_ANY_SCORE = false;
-  public static final boolean DEF_COPY_MAX_SCORE = false;
-  public static final boolean DEF_COPY_MIN_SCORE = false;
-  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
   public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
@@ -95,26 +85,10 @@
 
   public abstract String getName();
 
+  public abstract Optional<String> getDescription();
+
   public abstract LabelFunction getFunction();
 
-  public abstract boolean isCopyAnyScore();
-
-  public abstract boolean isCopyMinScore();
-
-  public abstract boolean isCopyMaxScore();
-
-  public abstract boolean isCopyAllScoresIfListOfFilesDidNotChange();
-
-  public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
-
-  public abstract boolean isCopyAllScoresOnTrivialRebase();
-
-  public abstract boolean isCopyAllScoresIfNoCodeChange();
-
-  public abstract boolean isCopyAllScoresIfNoChange();
-
-  public abstract ImmutableList<Short> getCopyValues();
-
   public abstract boolean isAllowPostSubmit();
 
   public abstract boolean isIgnoreSelfApproval();
@@ -141,24 +115,15 @@
   }
 
   public static LabelType.Builder builder(String name, List<LabelValue> valueList) {
-    return (new AutoValue_LabelType.Builder())
+    return new AutoValue_LabelType.Builder()
         .setName(name)
+        .setDescription(Optional.empty())
         .setValues(valueList)
         .setDefaultValue((short) 0)
         .setFunction(LabelFunction.MAX_WITH_BLOCK)
         .setMaxNegative(Short.MIN_VALUE)
         .setMaxPositive(Short.MAX_VALUE)
         .setCanOverride(DEF_CAN_OVERRIDE)
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
-        .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
-        .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
-        .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
-        .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
-        .setCopyAnyScore(DEF_COPY_ANY_SCORE)
-        .setCopyMaxScore(DEF_COPY_MAX_SCORE)
-        .setCopyMinScore(DEF_COPY_MIN_SCORE)
-        .setCopyValues(DEF_COPY_VALUES)
         .setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT)
         .setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
   }
@@ -167,6 +132,7 @@
     return psa.labelId().get().equalsIgnoreCase(getName());
   }
 
+  @Nullable
   public LabelValue getMin() {
     if (getValues().isEmpty()) {
       return null;
@@ -174,6 +140,7 @@
     return getValues().get(0);
   }
 
+  @Nullable
   public LabelValue getMax() {
     if (getValues().isEmpty()) {
       return null;
@@ -182,11 +149,19 @@
   }
 
   public boolean isMaxNegative(PatchSetApproval ca) {
-    return getMaxNegative() == ca.value();
+    return isMaxNegative(ca.value());
+  }
+
+  public boolean isMaxNegative(short value) {
+    return getMaxNegative() == value;
   }
 
   public boolean isMaxPositive(PatchSetApproval ca) {
-    return getMaxPositive() == ca.value();
+    return isMaxPositive(ca.value());
+  }
+
+  public boolean isMaxPositive(short value) {
+    return getMaxPositive() == value;
   }
 
   public LabelValue getValue(short value) {
@@ -226,6 +201,8 @@
   public abstract static class Builder {
     public abstract Builder setName(String name);
 
+    public abstract Builder setDescription(Optional<String> description);
+
     public abstract Builder setFunction(LabelFunction function);
 
     public abstract Builder setCanOverride(boolean canOverride);
@@ -240,28 +217,8 @@
 
     public abstract Builder setDefaultValue(short defaultValue);
 
-    public abstract Builder setCopyAnyScore(boolean copyAnyScore);
-
     public abstract Builder setCopyCondition(@Nullable String copyCondition);
 
-    public abstract Builder setCopyMinScore(boolean copyMinScore);
-
-    public abstract Builder setCopyMaxScore(boolean copyMaxScore);
-
-    public abstract Builder setCopyAllScoresIfListOfFilesDidNotChange(
-        boolean copyAllScoresIfListOfFilesDidNotChange);
-
-    public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
-        boolean copyAllScoresOnMergeFirstParentUpdate);
-
-    public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
-
-    public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
-
-    public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
-
-    public abstract Builder setCopyValues(Collection<Short> copyValues);
-
     public abstract Builder setMaxNegative(short maxNegative);
 
     public abstract Builder setMaxPositive(short maxPositive);
@@ -270,8 +227,6 @@
 
     protected abstract String getName();
 
-    protected abstract ImmutableList<Short> getCopyValues();
-
     protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
 
     @Nullable
@@ -303,8 +258,6 @@
       }
       setByValue(byValue.build());
 
-      setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
-
       return autoBuild();
     }
   }
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index 55a9976..a2f2e0b 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -69,7 +69,7 @@
 
   public Comparator<String> nameComparator() {
     final Map<String, Integer> positions = positions();
-    return new Comparator<String>() {
+    return new Comparator<>() {
       @Override
       public int compare(String left, String right) {
         int lp = position(left);
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index 2d28046..bef6580 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import java.util.List;
 
@@ -112,6 +113,7 @@
     }
 
     @UsedAt(UsedAt.Project.COLLABNET)
+    @Nullable
     public static ChangeType forCode(char c) {
       for (ChangeType s : ChangeType.values()) {
         if (s.code == c) {
@@ -168,33 +170,40 @@
    */
   public enum FileMode implements CodedEnum {
     /** Mode indicating an entry is a tree (aka directory). */
-    TREE('T'),
+    TREE('T', 0040000),
 
     /** Mode indicating an entry is a symbolic link. */
-    SYMLINK('S'),
+    SYMLINK('S', 0120000),
 
     /** Mode indicating an entry is a non-executable file. */
-    REGULAR_FILE('R'),
+    REGULAR_FILE('R', 0100644),
 
     /** Mode indicating an entry is an executable file. */
-    EXECUTABLE_FILE('E'),
+    EXECUTABLE_FILE('E', 0100755),
 
     /** Mode indicating an entry is a submodule commit in another repository. */
-    GITLINK('G'),
+    GITLINK('G', 0160000),
 
     /** Mode indicating an entry is missing during parallel walks. */
-    MISSING('M');
+    MISSING('M', 0000000);
 
     private final char code;
 
-    FileMode(char c) {
+    private final int mode;
+
+    FileMode(char c, int m) {
       code = c;
+      mode = m;
     }
 
     @Override
     public char getCode() {
       return code;
     }
+
+    public int getMode() {
+      return mode;
+    }
   }
 
   private Patch() {}
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index b26e5c3..354981c 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -22,7 +22,9 @@
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
-import java.sql.Timestamp;
+import com.google.errorprone.annotations.InlineMe;
+import com.google.gerrit.common.Nullable;
+import java.time.Instant;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,6 +43,9 @@
    * @deprecated use isChangeRef instead.
    */
   @Deprecated
+  @InlineMe(
+      replacement = "PatchSet.isChangeRef(name)",
+      imports = "com.google.gerrit.entities.PatchSet")
   public static boolean isRef(String name) {
     return isChangeRef(name);
   }
@@ -62,7 +67,7 @@
   }
 
   @AutoValue
-  public abstract static class Id {
+  public abstract static class Id implements Comparable<Id> {
     /** Parse a PatchSet.Id out of a string representation. */
     public static Id parse(String str) {
       List<String> parts = Splitter.on(',').splitToList(str);
@@ -79,6 +84,7 @@
     }
 
     /** Parse a PatchSet.Id from a {@link #refName()} result. */
+    @Nullable
     public static Id fromRef(String ref) {
       int cs = Change.Id.startIndex(ref);
       if (cs < 0) {
@@ -141,6 +147,11 @@
     public final String toString() {
       return getCommaSeparatedChangeAndPatchSetId();
     }
+
+    @Override
+    public int compareTo(Id other) {
+      return Ints.compare(get(), other.get());
+    }
   }
 
   public static Builder builder() {
@@ -159,7 +170,7 @@
 
     public abstract Builder uploader(Account.Id uploader);
 
-    public abstract Builder createdOn(Timestamp createdOn);
+    public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder groups(Iterable<String> groups);
 
@@ -206,7 +217,7 @@
    * 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();
+  public abstract Instant createdOn();
 
   /**
    * Opaque group identifier, usually assigned during creation.
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index f853f77..608cf0d 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -16,8 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Shorts;
-import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 import java.util.Optional;
 
 /** An approval (or negative approval) on a patch set. */
@@ -40,6 +39,36 @@
     }
   }
 
+  /**
+   * Globally unique identifier.
+   *
+   * <p>The identifier is unique to each granted approval, i.e. approvals, re-added within same
+   * {@link Change} or even {@link PatchSet} have different {@link UUID}.
+   */
+  @AutoValue
+  public abstract static class UUID implements Comparable<UUID> {
+
+    abstract String uuid();
+
+    public String get() {
+      return uuid();
+    }
+
+    @Override
+    public final int compareTo(UUID o) {
+      return uuid().compareTo(o.uuid());
+    }
+
+    @Override
+    public final String toString() {
+      return get();
+    }
+  }
+
+  public static UUID uuid(String n) {
+    return new AutoValue_PatchSetApproval_UUID(n);
+  }
+
   public static Builder builder() {
     return new AutoValue_PatchSetApproval.Builder().postSubmit(false).copied(false);
   }
@@ -50,17 +79,24 @@
 
     public abstract Key key();
 
+    /**
+     * {@link UUID} of {@link PatchSetApproval}.
+     *
+     * <p>Optional, since it might be missing for approvals, granted (persisted in NoteDB), before
+     * {@link UUID} was introduced and does not apply to removals ( represented as approval with
+     * {@link #value}, set to '0').
+     */
+    public abstract Builder uuid(Optional<UUID> uuid);
+
+    public abstract Builder uuid(UUID uuid);
+
     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 granted(Instant granted);
 
     public abstract Builder tag(String tag);
 
@@ -86,6 +122,8 @@
 
   public abstract Key key();
 
+  public abstract Optional<UUID> uuid();
+
   /**
    * Value assigned by the user.
    *
@@ -104,7 +142,7 @@
    */
   public abstract short value();
 
-  public abstract Timestamp granted();
+  public abstract Instant granted();
 
   public abstract Optional<String> tag();
 
@@ -117,8 +155,24 @@
 
   public abstract Builder toBuilder();
 
+  /**
+   * Makes a copy of {@link PatchSetApproval} that applies to {@code psId}.
+   *
+   * <p>The returned {@link PatchSetApproval} has the same {@link UUID} as the original {@link
+   * PatchSetApproval}, which is generated when it is originally granted.
+   *
+   * <p>This is needed since we want to keep the link between the original {@link PatchSetApproval}
+   * and the {@link #copied} one.
+   *
+   * @param psId {@link PatchSet.Id} of {@link PatchSet} that the copy should be applied to.
+   * @return {@link #copied} {@link PatchSetApproval} that applies to {@code psId}.
+   */
   public PatchSetApproval copyWithPatchSet(PatchSet.Id psId) {
-    return toBuilder().key(key(psId, key().accountId(), key().labelId())).copied(true).build();
+    return toBuilder()
+        .key(key(psId, key().accountId(), key().labelId()))
+        .uuid(uuid())
+        .copied(true)
+        .build();
   }
 
   public PatchSet.Id patchSetId() {
diff --git a/java/com/google/gerrit/entities/PatchSetApprovals.java b/java/com/google/gerrit/entities/PatchSetApprovals.java
new file mode 100644
index 0000000..8fed9c0
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSetApprovals.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimaps;
+
+/** All approvals of a change by patch set. */
+@AutoValue
+public abstract class PatchSetApprovals {
+  /**
+   * Returns all approvals by patch set, including copied approvals
+   *
+   * <p>Approvals that have been copied from a previous patch set are returned as part of the
+   * result. These approvals can be identified by looking at {@link PatchSetApproval#copied()}.
+   */
+  public abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> all();
+
+  /**
+   * Returns non-copied approvals by patch set.
+   *
+   * <p>Approvals that have been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyNonCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> !entry.getValue().copied()));
+  }
+
+  /**
+   * Returns copied approvals by patch set.
+   *
+   * <p>Approvals that have not been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> entry.getValue().copied()));
+  }
+
+  public static PatchSetApprovals create(
+      ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsByPatchSet) {
+    return new AutoValue_PatchSetApprovals(approvalsByPatchSet);
+  }
+}
diff --git a/java/com/google/gerrit/entities/PatchSetInfo.java b/java/com/google/gerrit/entities/PatchSetInfo.java
index e3c6613..5770a7e 100644
--- a/java/com/google/gerrit/entities/PatchSetInfo.java
+++ b/java/com/google/gerrit/entities/PatchSetInfo.java
@@ -31,33 +31,33 @@
       this.shortMessage = requireNonNull(shortMessage);
     }
 
-    protected ParentInfo() {}
+    ParentInfo() {}
   }
 
-  protected PatchSet.Id key;
+  private PatchSet.Id key;
 
   /** First line of {@link #message}. */
-  protected String subject;
+  private String subject;
 
   /** The complete description of the change the patch set introduces. */
-  protected String message;
+  private String message;
 
   /** Identity of who wrote the patch set. May differ from {@link #committer}. */
-  protected UserIdentity author;
+  private UserIdentity author;
 
   /** Identity of who committed the patch set to the VCS. */
-  protected UserIdentity committer;
+  private UserIdentity committer;
 
   /** List of parents of the patch set. */
-  protected List<ParentInfo> parents;
+  private List<ParentInfo> parents;
 
   /** ID of commit. */
-  protected ObjectId commitId;
+  private ObjectId commitId;
 
   /** Optional user-supplied description for the patch set. */
-  protected String description;
+  private String description;
 
-  protected PatchSetInfo() {}
+  PatchSetInfo() {}
 
   public PatchSetInfo(PatchSet.Id k) {
     key = k;
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 95164bd..d029fad 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -43,6 +43,7 @@
   public static final String FORGE_SERVER = "forgeServerAsCommitter";
   public static final String LABEL = "label-";
   public static final String LABEL_AS = "labelAs-";
+  public static final String REMOVE_LABEL = "removeLabel-";
   public static final String OWNER = "owner";
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
@@ -60,6 +61,7 @@
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
   private static final int LABEL_AS_INDEX;
+  private static final int REMOVE_LABEL_INDEX;
 
   static {
     NAMES_LC = new ArrayList<>();
@@ -79,6 +81,7 @@
     NAMES_LC.add(FORGE_SERVER.toLowerCase());
     NAMES_LC.add(LABEL.toLowerCase());
     NAMES_LC.add(LABEL_AS.toLowerCase());
+    NAMES_LC.add(REMOVE_LABEL.toLowerCase());
     NAMES_LC.add(OWNER.toLowerCase());
     NAMES_LC.add(PUSH.toLowerCase());
     NAMES_LC.add(PUSH_MERGE.toLowerCase());
@@ -93,15 +96,19 @@
 
     LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
     LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+    REMOVE_LABEL_INDEX = NAMES_LC.indexOf(Permission.REMOVE_LABEL.toLowerCase());
   }
 
   /** Returns true if the name is recognized as a permission name. */
   public static boolean isPermission(String varName) {
-    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+    return isLabel(varName)
+        || isLabelAs(varName)
+        || isRemoveLabel(varName)
+        || NAMES_LC.contains(varName.toLowerCase());
   }
 
   public static boolean hasRange(String varName) {
-    return isLabel(varName) || isLabelAs(varName);
+    return isLabel(varName) || isLabelAs(varName) || isRemoveLabel(varName);
   }
 
   /** Returns true if the permission name is actually for a review label. */
@@ -114,6 +121,11 @@
     return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
   }
 
+  /** Returns true if the permission is for impersonated review labels. */
+  public static boolean isRemoveLabel(String var) {
+    return var.startsWith(REMOVE_LABEL) && REMOVE_LABEL.length() < var.length();
+  }
+
   /** Returns permission name for the given review label. */
   public static String forLabel(String labelName) {
     return LABEL + labelName;
@@ -124,11 +136,19 @@
     return LABEL_AS + labelName;
   }
 
+  /** Returns permission name to remove a label for another user. */
+  public static String forRemoveLabel(String labelName) {
+    return REMOVE_LABEL + labelName;
+  }
+
+  @Nullable
   public static String extractLabel(String varName) {
     if (isLabel(varName)) {
       return varName.substring(LABEL.length());
     } else if (isLabelAs(varName)) {
       return varName.substring(LABEL_AS.length());
+    } else if (isRemoveLabel(varName)) {
+      return varName.substring(REMOVE_LABEL.length());
     }
     return null;
   }
@@ -204,6 +224,8 @@
       return LABEL_INDEX;
     } else if (isLabelAs(a.getName())) {
       return LABEL_AS_INDEX;
+    } else if (isRemoveLabel(a.getName())) {
+      return REMOVE_LABEL_INDEX;
     }
 
     int index = NAMES_LC.indexOf(a.getName().toLowerCase());
@@ -277,7 +299,10 @@
 
     public Permission build() {
       setRules(
-          rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+          rulesBuilders.stream()
+              .map(PermissionRule.Builder::build)
+              .distinct()
+              .collect(toImmutableList()));
       return autoBuild();
     }
 
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
index 9a2d31e..1665c1c 100644
--- a/java/com/google/gerrit/entities/PermissionRule.java
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -202,6 +202,9 @@
         int dotdot = range.indexOf("..");
         int min = parseInt(range.substring(0, dotdot));
         int max = parseInt(range.substring(dotdot + 2));
+        if (min > max) {
+          throw new IllegalArgumentException("Invalid range in rule: " + orig);
+        }
         rule.setRange(min, max);
       } else {
         throw new IllegalArgumentException("Invalid range in rule: " + orig);
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 617b827..b587b1d 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -166,6 +166,7 @@
    * @return name key of the parent project, {@code null} if this project is the All-Projects
    *     project
    */
+  @Nullable
   public Project.NameKey getParent(Project.NameKey allProjectsName) {
     if (getParent() != null) {
       return getParent();
@@ -178,6 +179,7 @@
     return allProjectsName;
   }
 
+  @Nullable
   public String getParentName() {
     return getParent() != null ? getParent().get() : null;
   }
diff --git a/java/com/google/gerrit/entities/ProjectChangeKey.java b/java/com/google/gerrit/entities/ProjectChangeKey.java
new file mode 100644
index 0000000..15dc5e9
--- /dev/null
+++ b/java/com/google/gerrit/entities/ProjectChangeKey.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+
+/** Stores together numeric {@link Change.Id} and a project name for the change */
+@AutoValue
+public abstract class ProjectChangeKey {
+  public static ProjectChangeKey create(Project.NameKey projectName, Change.Id changeId) {
+    return new AutoValue_ProjectChangeKey(projectName, changeId);
+  }
+
+  public abstract Project.NameKey projectName();
+
+  public abstract Change.Id changeId();
+}
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 349b67e..9745fc5 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import java.util.List;
 
 /** Constants and utilities for Gerrit-specific ref names. */
 public class RefNames {
@@ -126,6 +129,8 @@
           REFS_STARRED_CHANGES,
           REFS_REJECT_COMMITS);
 
+  private static final Splitter SPLITTER = Splitter.on("/");
+
   public static String fullName(String ref) {
     return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
@@ -218,6 +223,7 @@
     return REFS_CACHE_AUTOMERGE + hash.substring(0, 2) + '/' + hash.substring(2);
   }
 
+  @Nullable
   public static String shard(int id) {
     if (id < 0) {
       return null;
@@ -339,21 +345,22 @@
     return GERRIT_REFS.stream().anyMatch(internalRef -> ref.startsWith(internalRef));
   }
 
+  @Nullable
   static Integer parseShardedRefPart(String name) {
     if (name == null) {
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n < 2) {
       return null;
     }
 
     // Last 2 digits.
     int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
+    for (le = 0; le < parts.get(0).length(); le++) {
+      if (!Character.isDigit(parts.get(0).charAt(le))) {
         return null;
       }
     }
@@ -363,8 +370,8 @@
 
     // Full ID.
     int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
+    for (ie = 0; ie < parts.get(1).length(); ie++) {
+      if (!Character.isDigit(parts.get(1).charAt(ie))) {
         if (ie == 0) {
           return null;
         }
@@ -372,8 +379,8 @@
       }
     }
 
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
+    int shard = Integer.parseInt(parts.get(0));
+    int id = Integer.parseInt(parts.get(1).substring(0, ie));
 
     if (id % 100 != shard) {
       return null;
@@ -382,25 +389,26 @@
   }
 
   @UsedAt(UsedAt.Project.PLUGINS_ALL)
+  @Nullable
   public static String parseShardedUuidFromRefPart(String name) {
     if (name == null) {
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n != 2) {
       return null;
     }
 
     // First 2 chars.
-    if (parts[0].length() != 2) {
+    if (parts.get(0).length() != 2) {
       return null;
     }
 
     // Full UUID.
-    String uuid = parts[1];
-    if (!uuid.startsWith(parts[0])) {
+    String uuid = parts.get(1);
+    if (!uuid.startsWith(parts.get(0))) {
       return null;
     }
 
@@ -416,21 +424,22 @@
    * @return the rest of the name, {@code null} if the ref name part doesn't start with a valid
    *     sharded ID
    */
+  @Nullable
   static String skipShardedRefPart(String name) {
     if (name == null) {
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n < 2) {
       return null;
     }
 
     // Last 2 digits.
     int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
+    for (le = 0; le < parts.get(0).length(); le++) {
+      if (!Character.isDigit(parts.get(0).charAt(le))) {
         return null;
       }
     }
@@ -440,8 +449,8 @@
 
     // Full ID.
     int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
+    for (ie = 0; ie < parts.get(1).length(); ie++) {
+      if (!Character.isDigit(parts.get(1).charAt(ie))) {
         if (ie == 0) {
           return null;
         }
@@ -449,8 +458,8 @@
       }
     }
 
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
+    int shard = Integer.parseInt(parts.get(0));
+    int id = Integer.parseInt(parts.get(1).substring(0, ie));
 
     if (id % 100 != shard) {
       return null;
@@ -469,6 +478,7 @@
    *     ref name part doesn't start with a valid sharded ID or if no valid ID follows the sharded
    *     ref part
    */
+  @Nullable
   static Integer parseAfterShardedRefPart(String name) {
     String rest = skipShardedRefPart(name);
     if (rest == null || !rest.startsWith("/")) {
@@ -489,6 +499,7 @@
     return Integer.parseInt(rest.substring(0, ie));
   }
 
+  @Nullable
   public static Integer parseRefSuffix(String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index e2e4114..1d46d3b 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -29,7 +29,7 @@
   public RobotComment(
       Key key,
       Account.Id author,
-      Timestamp writtenOn,
+      Instant writtenOn,
       short side,
       String message,
       String serverId,
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index f298782..5ee76da 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -30,13 +30,28 @@
   @Nullable
   public abstract String getMatch();
 
-  /** The link to replace the match with. This can only be set if html is {@code null}. */
+  /**
+   * The link to replace the match with.
+   *
+   * <p>The constructed link is using {@link #getLink()} {@link #getPrefix()} {@link #getSuffix()}
+   * and {@link #getText()}, and has the shape of
+   *
+   * <p>{@code PREFIX<a href="LINK">TEXT</a>SUFFIX}
+   */
   @Nullable
   public abstract String getLink();
 
-  /** The html to replace the match with. This can only be set if link is {@code null}. */
+  /** The optional text before the link tag that the match is replaced with. */
   @Nullable
-  public abstract String getHtml();
+  public abstract String getPrefix();
+
+  /** The optional text after the link tag that the match is replaced with. */
+  @Nullable
+  public abstract String getSuffix();
+
+  /** The content of the link tag that the match is replaced with. If not set full match is used. */
+  @Nullable
+  public abstract String getText();
 
   /** Weather this comment link is active. {@code null} means true. */
   @Nullable
@@ -58,7 +73,7 @@
    * on it's own.
    */
   public static StoredCommentLinkInfo disabled(String name) {
-    return builder(name).setOverrideOnly(true).build();
+    return builder(name).setOverrideOnly(true).setEnabled(false).build();
   }
 
   /** Creates and returns a new {@link StoredCommentLinkInfo.Builder} instance. */
@@ -72,7 +87,9 @@
     return builder(src.name)
         .setMatch(src.match)
         .setLink(src.link)
-        .setHtml(src.html)
+        .setPrefix(src.prefix)
+        .setSuffix(src.suffix)
+        .setText(src.text)
         .setEnabled(enabled)
         .setOverrideOnly(false)
         .build();
@@ -84,7 +101,9 @@
     info.name = getName();
     info.match = getMatch();
     info.link = getLink();
-    info.html = getHtml();
+    info.prefix = getPrefix();
+    info.suffix = getSuffix();
+    info.text = getText();
     info.enabled = getEnabled();
     return info;
   }
@@ -97,7 +116,11 @@
 
     public abstract Builder setLink(@Nullable String value);
 
-    public abstract Builder setHtml(@Nullable String value);
+    public abstract Builder setPrefix(@Nullable String value);
+
+    public abstract Builder setSuffix(@Nullable String value);
+
+    public abstract Builder setText(@Nullable String value);
 
     public abstract Builder setEnabled(@Nullable Boolean value);
 
@@ -105,14 +128,15 @@
 
     public StoredCommentLinkInfo build() {
       checkArgument(getName() != null, "invalid commentlink.name");
-      setLink(Strings.emptyToNull(getLink()));
-      setHtml(Strings.emptyToNull(getHtml()));
+      setPrefix(Strings.emptyToNull(getPrefix()));
+      setSuffix(Strings.emptyToNull(getSuffix()));
+      setText(Strings.emptyToNull(getText()));
       if (!getOverrideOnly()) {
         checkArgument(
             !Strings.isNullOrEmpty(getMatch()), "invalid commentlink.%s.match", getName());
         checkArgument(
-            (getLink() != null && getHtml() == null) || (getLink() == null && getHtml() != null),
-            "commentlink.%s must have either link or html",
+            !Strings.isNullOrEmpty(getLink()),
+            "commentlink.%s must have link specified",
             getName());
       }
       return autoBuild();
@@ -126,7 +150,11 @@
 
     protected abstract String getLink();
 
-    protected abstract String getHtml();
+    protected abstract String getPrefix();
+
+    protected abstract String getSuffix();
+
+    protected abstract String getText();
 
     protected abstract boolean getOverrideOnly();
   }
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 95ad9f8..4142b42 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -70,7 +70,7 @@
 
   // Name of the rule that created this submit record, formatted as '$pluginName~$ruleName'
   public String ruleName;
-  public Status status;
+  public SubmitRecord.Status status;
   public List<Label> labels;
   public List<LegacySubmitRequirement> requirements;
   public String errorMessage;
@@ -113,7 +113,7 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public Account.Id appliedBy;
 
     /**
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 13e0b53..523b993 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -1,25 +1,37 @@
-//  Copyright (C) 2021 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
-/** Entity describing a requirement that should be met for a change to become submittable. */
+/**
+ * Entity describing a requirement that should be met for a change to become submittable.
+ *
+ * <p>There are two ways to contribute {@link SubmitRequirement}:
+ *
+ * <ul>
+ *   <li>Set per-project in project.config (see {@link
+ *       com.google.gerrit.server.project.ProjectState#getSubmitRequirements()}
+ *   <li>Bind a global {@link SubmitRequirement} that will be evaluated for all projects.
+ * </ul>
+ */
+@ExtensionPoint
 @AutoValue
 public abstract class SubmitRequirement {
   /** Requirement name. */
@@ -32,7 +44,7 @@
    * Expression of the condition that makes the requirement applicable. The expression should be
    * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
    * irrelevant for the change (i.e. {@link #submittabilityExpression()} and {@link
-   * #overrideExpression()} become irrelevant).
+   * #overrideExpression()} are not evaluated).
    *
    * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
    */
@@ -56,7 +68,12 @@
 
   /**
    * Boolean value indicating if the {@link SubmitRequirement} definition can be overridden in child
-   * projects. Default is false.
+   * projects.
+   *
+   * <p>For globally bound {@link SubmitRequirement}, indicates if can be overridden by {@link
+   * SubmitRequirement} in project.config.
+   *
+   * <p>Default is false.
    */
   public abstract boolean allowOverrideInChildProjects();
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index 2af1379..abac3e6 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -47,4 +47,12 @@
   public static TypeAdapter<SubmitRequirementExpression> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpression.GsonTypeAdapter(gson);
   }
+
+  /**
+   * Returns a "submit requirement" expression that requires the maximum vote on the Code-Review
+   * label.
+   */
+  public static SubmitRequirementExpression maxCodeReview() {
+    return SubmitRequirementExpression.create(String.format("label:Code-Review=MAX"));
+  }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index 9e4416b..fbb2fd7 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -72,8 +72,17 @@
       Status status,
       ImmutableList<String> passingAtoms,
       ImmutableList<String> failingAtoms) {
+    return create(expression, status, passingAtoms, failingAtoms, Optional.empty());
+  }
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> passingAtoms,
+      ImmutableList<String> failingAtoms,
+      Optional<String> errorMessage) {
     return new AutoValue_SubmitRequirementExpressionResult(
-        expression, status, Optional.empty(), passingAtoms, failingAtoms);
+        expression, status, errorMessage, passingAtoms, failingAtoms);
   }
 
   public static SubmitRequirementExpressionResult error(
@@ -86,10 +95,32 @@
         ImmutableList.of());
   }
 
+  public static SubmitRequirementExpressionResult notEvaluated(SubmitRequirementExpression expr) {
+    return SubmitRequirementExpressionResult.create(
+        expr, Status.NOT_EVALUATED, ImmutableList.of(), ImmutableList.of());
+  }
+
   public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
   }
 
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder expression(SubmitRequirementExpression expression);
+
+    public abstract Builder status(Status status);
+
+    public abstract Builder errorMessage(Optional<String> errorMessage);
+
+    public abstract Builder passingAtoms(ImmutableList<String> passingAtoms);
+
+    public abstract Builder failingAtoms(ImmutableList<String> failingAtoms);
+
+    public abstract SubmitRequirementExpressionResult build();
+  }
+
   public enum Status {
     /** Submit requirement expression is fulfilled for a given change. */
     PASS,
@@ -98,7 +129,10 @@
     FAIL,
 
     /** Submit requirement expression contains invalid syntax and is not parsable. */
-    ERROR
+    ERROR,
+
+    /** Submit requirement expression was not evaluated. */
+    NOT_EVALUATED
   }
 
   /**
@@ -163,8 +197,6 @@
 
     @AutoValue.Builder
     public abstract static class Builder {
-      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
-
       protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
 
       public abstract Builder predicateString(String value);
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index 13625c1..d9bb162 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gerrit.common.Nullable;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
@@ -31,11 +32,18 @@
   public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
 
   /**
-   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
+   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} on a change.
+   *
+   * <p>Empty if submit requirement does not apply.
    */
-  public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
+  public abstract Optional<SubmitRequirementExpressionResult> submittabilityExpressionResult();
 
-  /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
+  /**
+   * Result of evaluating a {@link SubmitRequirement#overrideExpression()} on a change.
+   *
+   * <p>Empty if submit requirement does not apply, or if the submit requirement did not define an
+   * override expression.
+   */
   public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
 
   /** SHA-1 of the patchset commit ID for which the submit requirement was evaluated. */
@@ -53,9 +61,56 @@
     return legacy().orElse(false);
   }
 
+  /**
+   * Boolean indicating if the "submit requirement" was bypassed during submission, e.g. by
+   * performing a push with the %submit option.
+   */
+  public abstract Optional<Boolean> forced();
+
+  /**
+   * Whether this result should be filtered out when returned from REST API.
+   *
+   * <p>This can be used by {@link
+   * com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier}. It can override the
+   * {@code SubmitRequirementResult} status and might want to hide the SR from the API as if it was
+   * non-applicable (non-applicable SRs are currently hidden on UI).
+   */
+  public abstract Optional<Boolean> hidden();
+
+  public boolean isHidden() {
+    return hidden().orElse(false);
+  }
+
+  public Optional<String> errorMessage() {
+    if (!status().equals(Status.ERROR)) {
+      return Optional.empty();
+    }
+    if (applicabilityExpressionResult().isPresent()
+        && applicabilityExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Applicability expression result has an error: "
+              + applicabilityExpressionResult().get().errorMessage().get());
+    }
+    if (submittabilityExpressionResult().isPresent()
+        && submittabilityExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Submittability expression result has an error: "
+              + submittabilityExpressionResult().get().errorMessage().get());
+    }
+    if (overrideExpressionResult().isPresent()
+        && overrideExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Override expression result has an error: "
+              + overrideExpressionResult().get().errorMessage().get());
+    }
+    return Optional.of("No error logged.");
+  }
+
   @Memoized
   public Status status() {
-    if (assertError(submittabilityExpressionResult())
+    if (forced().orElse(false)) {
+      return Status.FORCED;
+    } else if (assertError(submittabilityExpressionResult())
         || assertError(applicabilityExpressionResult())
         || assertError(overrideExpressionResult())) {
       return Status.ERROR;
@@ -74,13 +129,18 @@
   @Memoized
   public boolean fulfilled() {
     Status s = status();
-    return s == Status.SATISFIED || s == Status.OVERRIDDEN || s == Status.NOT_APPLICABLE;
+    return s == Status.SATISFIED
+        || s == Status.OVERRIDDEN
+        || s == Status.NOT_APPLICABLE
+        || s == Status.FORCED;
   }
 
   public static Builder builder() {
     return new AutoValue_SubmitRequirementResult.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   public static TypeAdapter<SubmitRequirementResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementResult.GsonTypeAdapter(gson);
   }
@@ -108,10 +168,16 @@
     NOT_APPLICABLE,
 
     /**
-     * Any of the applicability, blocking or override expressions contain invalid syntax and are not
-     * parsable.
+     * Any of the applicability, submittability or override expressions contain invalid syntax and
+     * are not parsable.
      */
-    ERROR
+    ERROR,
+
+    /**
+     * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
+     * %submit option.
+     */
+    FORCED
   }
 
   @AutoValue.Builder
@@ -121,7 +187,11 @@
     public abstract Builder applicabilityExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
 
-    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+    public abstract Builder submittabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(
+        @Nullable SubmitRequirementExpressionResult value);
 
     public abstract Builder overrideExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
@@ -130,36 +200,32 @@
 
     public abstract Builder legacy(Optional<Boolean> value);
 
+    public abstract Builder forced(Optional<Boolean> value);
+
+    public abstract Builder hidden(Optional<Boolean> value);
+
     public abstract SubmitRequirementResult build();
   }
 
-  private boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
   }
 
-  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
-    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
-  }
-
-  private boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.FAIL);
   }
 
-  private boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
   }
 
-  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
-    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
-  }
-
-  private boolean assertStatus(
+  private static boolean assertStatus(
       SubmitRequirementExpressionResult expressionResult,
       SubmitRequirementExpressionResult.Status status) {
     return expressionResult.status() == status;
   }
 
-  private boolean assertStatus(
+  private static boolean assertStatus(
       Optional<SubmitRequirementExpressionResult> expressionResult,
       SubmitRequirementExpressionResult.Status status) {
     return expressionResult.isPresent() && assertStatus(expressionResult.get(), status);
diff --git a/java/com/google/gerrit/entities/SubmoduleSubscription.java b/java/com/google/gerrit/entities/SubmoduleSubscription.java
index 5ea1b1e..db26eb3 100644
--- a/java/com/google/gerrit/entities/SubmoduleSubscription.java
+++ b/java/com/google/gerrit/entities/SubmoduleSubscription.java
@@ -25,11 +25,11 @@
  * <p>A subscriber operates a submodule in defined path.
  */
 public final class SubmoduleSubscription {
-  protected BranchNameKey superProject;
+  private BranchNameKey superProject;
 
-  protected String submodulePath;
+  private String submodulePath;
 
-  protected BranchNameKey submodule;
+  private BranchNameKey submodule;
 
   public SubmoduleSubscription(BranchNameKey superProject, BranchNameKey submodule, String path) {
     this.superProject = superProject;
diff --git a/java/com/google/gerrit/entities/UserIdentity.java b/java/com/google/gerrit/entities/UserIdentity.java
index e07d21a..8334157 100644
--- a/java/com/google/gerrit/entities/UserIdentity.java
+++ b/java/com/google/gerrit/entities/UserIdentity.java
@@ -14,26 +14,26 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public final class UserIdentity {
   /** Full name of the user. */
-  protected String name;
+  private String name;
 
   /** Email address (or user@host style string anyway). */
-  protected String email;
+  private String email;
 
   /** Username of the user. */
-  protected String username;
+  private String username;
 
   /** Time (in UTC) when the identity was constructed. */
-  protected Timestamp when;
+  private Instant when;
 
   /** Offset from UTC */
-  protected int tz;
+  private int tz;
 
   /** If the user has a Gerrit account, their account identity. */
-  protected Account.Id accountId;
+  private Account.Id accountId;
 
   public String getName() {
     return name;
@@ -55,11 +55,11 @@
     return username;
   }
 
-  public Timestamp getDate() {
+  public Instant getDate() {
     return when;
   }
 
-  public void setDate(Timestamp d) {
+  public void setDate(Instant d) {
     when = d;
   }
 
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
index eb2a381..edf921e 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 @Immutable
@@ -44,9 +44,9 @@
     if (author != null) {
       builder.setAuthorId(accountIdConverter.toProto(author));
     }
-    Timestamp writtenOn = changeMessage.getWrittenOn();
+    Instant writtenOn = changeMessage.getWrittenOn();
     if (writtenOn != null) {
-      builder.setWrittenOn(writtenOn.getTime());
+      builder.setWrittenOn(writtenOn.toEpochMilli());
     }
     // Build proto with template representation of the message. Templates are parsed when message is
     // extracted from cache.
@@ -78,7 +78,7 @@
         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;
+    Instant writtenOn = proto.hasWrittenOn() ? Instant.ofEpochMilli(proto.getWrittenOn()) : null;
     PatchSet.Id patchSetId =
         proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
     // Only template representation of the message is stored in entity. Templates should be replaced
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 689b4aa..4903364 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @Immutable
 public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Change> {
@@ -44,8 +44,8 @@
         Entities.Change.newBuilder()
             .setChangeId(changeIdConverter.toProto(change.getId()))
             .setChangeKey(changeKeyConverter.toProto(change.getKey()))
-            .setCreatedOn(change.getCreatedOn().getTime())
-            .setLastUpdatedOn(change.getLastUpdatedOn().getTime())
+            .setCreatedOn(change.getCreatedOn().toEpochMilli())
+            .setLastUpdatedOn(change.getLastUpdatedOn().toEpochMilli())
             .setOwnerAccountId(accountIdConverter.toProto(change.getOwner()))
             .setDest(branchNameConverter.toProto(change.getDest()))
             .setStatus(change.getStatus().getCode())
@@ -96,9 +96,9 @@
     BranchNameKey destination =
         proto.hasDest() ? branchNameConverter.fromProto(proto.getDest()) : null;
     Change change =
-        new Change(key, changeId, owner, destination, new Timestamp(proto.getCreatedOn()));
+        new Change(key, changeId, owner, destination, Instant.ofEpochMilli(proto.getCreatedOn()));
     if (proto.hasLastUpdatedOn()) {
-      change.setLastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()));
+      change.setLastUpdatedOn(Instant.ofEpochMilli(proto.getLastUpdatedOn()));
     }
     Change.Status status = Change.Status.forCode((char) proto.getStatus());
     if (status != null) {
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
index 9e77025..e8ef346 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 @Immutable
@@ -38,10 +38,11 @@
         Entities.PatchSetApproval.newBuilder()
             .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.key()))
             .setValue(patchSetApproval.value())
-            .setGranted(patchSetApproval.granted().getTime())
+            .setGranted(patchSetApproval.granted().toEpochMilli())
             .setPostSubmit(patchSetApproval.postSubmit())
             .setCopied(patchSetApproval.copied());
 
+    patchSetApproval.uuid().ifPresent(uuid -> builder.setUuid(uuid.get()));
     patchSetApproval.tag().ifPresent(builder::setTag);
     Account.Id realAccountId = patchSetApproval.realAccountId();
     // PatchSetApproval#getRealAccountId automatically delegates to PatchSetApproval#getAccountId if
@@ -61,9 +62,12 @@
         PatchSetApproval.builder()
             .key(patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()))
             .value(proto.getValue())
-            .granted(new Timestamp(proto.getGranted()))
+            .granted(Instant.ofEpochMilli(proto.getGranted()))
             .postSubmit(proto.getPostSubmit())
             .copied(proto.getCopied());
+    if (proto.hasUuid()) {
+      builder.uuid(PatchSetApproval.uuid(proto.getUuid()));
+    }
     if (proto.hasTag()) {
       builder.tag(proto.getTag());
     }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 13a6e71..210972d 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -42,7 +42,7 @@
             .setId(patchSetIdConverter.toProto(patchSet.id()))
             .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
             .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
-            .setCreatedOn(patchSet.createdOn().getTime());
+            .setCreatedOn(patchSet.createdOn().toEpochMilli());
     List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
       builder.setGroups(PatchSet.joinGroups(groups));
@@ -84,7 +84,8 @@
             proto.hasUploaderAccountId()
                 ? accountIdConverter.fromProto(proto.getUploaderAccountId())
                 : Account.id(0))
-        .createdOn(proto.hasCreatedOn() ? new Timestamp(proto.getCreatedOn()) : new Timestamp(0));
+        .createdOn(
+            proto.hasCreatedOn() ? Instant.ofEpochMilli(proto.getCreatedOn()) : Instant.EPOCH);
 
     return builder.build();
   }
diff --git a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
deleted file mode 100644
index 452192c..0000000
--- a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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 InternalServerWithUserMessageException extends RuntimeException {
-  private static final long serialVersionUID = 1L;
-
-  public InternalServerWithUserMessageException(String msg, Throwable cause) {
-    super(msg, cause);
-  }
-}
diff --git a/java/com/google/gerrit/exceptions/MergeUpdateException.java b/java/com/google/gerrit/exceptions/MergeUpdateException.java
new file mode 100644
index 0000000..b60ca57
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/MergeUpdateException.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+/**
+ * An exception used for changes that fail to merge. This exception has a user visible message
+ * unlike other {@link RuntimeException}s, because this is our way to improve the UX when
+ * submission/merges fail.
+ */
+public class MergeUpdateException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public MergeUpdateException(String userVisibleMessage, Throwable cause) {
+    super(userVisibleMessage, cause);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
index a62fc63..b3680ea 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
@@ -34,7 +34,7 @@
     requireNonNull(pluginName, "pluginName");
     requireNonNull(permission, "permission");
     checkArgument(
-        isValidPluginPermissionName(permission), "invalid plugin permission name: ", permission);
+        isValidPluginPermissionName(permission), "invalid plugin permission name: %s", permission);
 
     this.pluginName = pluginName;
     this.permission = permission;
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
new file mode 100644
index 0000000..493329c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+/** Information about a patch to apply. */
+public class ApplyPatchInput {
+  /**
+   * Required. The patch to be applied.
+   *
+   * <p>Must be compatible with `git diff` output. For example, Gerrit API `Get Patch` output.
+   */
+  public String patch;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
new file mode 100644
index 0000000..872ea42
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.Nullable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
+/** Information for creating a new patch set from a given patch. */
+public class ApplyPatchPatchSetInput {
+
+  /** The patch to be applied. */
+  public ApplyPatchInput patch;
+
+  /**
+   * The commit message for the new patch set. If not specified, a predefined message will be used.
+   */
+  @Nullable public String commitMessage;
+
+  /**
+   * 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch
+   * set. If set, it must be a merged commit or a change revision on the destination branch.
+   * Otherwise, the target change's branch tip will be used.
+   */
+  @Nullable public String base;
+
+  /**
+   * The author of the new patch set. Must include both {@link AccountInput#name} and {@link
+   * AccountInput#email} fields.
+   */
+  @Nullable public AccountInput author;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 2224649..0ebb859 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,12 +27,14 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -128,20 +130,6 @@
   }
 
   /**
-   * Ignore or un-ignore this change.
-   *
-   * @param ignore ignore the change if true
-   */
-  void ignore(boolean ignore) throws RestApiException;
-
-  /**
-   * Check if this change is ignored.
-   *
-   * @return true if the change is ignored
-   */
-  boolean ignored() throws RestApiException;
-
-  /**
    * Create a new change that reverts this change.
    *
    * @see Changes#id(int)
@@ -166,6 +154,8 @@
   /** Create a merge patch set for the change. */
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
+  ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException;
+
   default List<ChangeInfo> submittedTogether() throws RestApiException {
     SubmittedTogetherInfo info =
         submittedTogether(
@@ -190,6 +180,24 @@
   /** Rebase the current revision of a change. */
   void rebase(RebaseInput in) throws RestApiException;
 
+  /**
+   * Rebase the current revisions of a change's chain using default options.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  default Response<RebaseChainInfo> rebaseChain() throws RestApiException {
+    return rebaseChain(new RebaseInput());
+  }
+
+  /**
+   * Rebase the current revisions of a change's chain.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException;
+
   /** Deletes a change. */
   void delete() throws RestApiException;
 
@@ -427,6 +435,39 @@
 
   ChangeInfo check(FixInput fix) throws RestApiException;
 
+  abstract class CheckSubmitRequirementRequest {
+    /** Submit requirement name. */
+    private String name;
+
+    /**
+     * A change ID for a change in {@link com.google.gerrit.entities.RefNames#REFS_CONFIG} branch
+     * from which the submit-requirement will be loaded.
+     */
+    private String refsConfigChangeId;
+
+    public abstract SubmitRequirementResultInfo get() throws RestApiException;
+
+    public CheckSubmitRequirementRequest srName(String srName) {
+      this.name = srName;
+      return this;
+    }
+
+    public CheckSubmitRequirementRequest refsConfigChangeId(String changeId) {
+      this.refsConfigChangeId = changeId;
+      return this;
+    }
+
+    protected String srName() {
+      return name;
+    }
+
+    protected String getRefsConfigChangeId() {
+      return refsConfigChangeId;
+    }
+  }
+
+  CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException;
+
   /** Returns the result of evaluating the {@link SubmitRequirementInput} input on the change. */
   SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException;
@@ -613,6 +654,11 @@
     }
 
     @Override
+    public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -767,6 +813,11 @@
     }
 
     @Override
+    public CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
         throws RestApiException {
       throw new NotImplementedException();
@@ -800,12 +851,7 @@
     }
 
     @Override
-    public void ignore(boolean ignore) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public boolean ignored() throws RestApiException {
+    public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index fb03bc5..232b2b5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -31,4 +31,5 @@
   public boolean allowConflicts;
   public String topic;
   public boolean allowEmpty;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
index ee10a1d..f75443e 100644
--- a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
@@ -25,4 +26,13 @@
   public NotifyHandling notify = NotifyHandling.ALL;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  /**
+   * Users in the attention set will not be added/removed from this endpoint call. Normally, users
+   * are added to the attention set upon deletion of their vote by other users.
+   */
+  public boolean ignoreAutomaticAttentionSetRules;
+
+  /** Reason for this vote deletion. Will appear in the change message. */
+  @Nullable public String reason;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
index 0cfe908..6349595 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -21,4 +21,5 @@
 public class FileContentInput {
   @DefaultInput public RawInput content;
   public String binary_content;
+  public Integer fileMode;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
new file mode 100644
index 0000000..ab81c60
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+/** Options for "Get Related Changes" requests. */
+public enum GetRelatedOption {
+  /** Compute submittability boolean for all returned changes. */
+  SUBMITTABLE
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index 10559a3..e9b05cc 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.Map;
+
 public class RebaseInput {
   public String base;
 
@@ -24,4 +26,6 @@
    * to indicate the conflicts.
    */
   public boolean allowConflicts;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
index 5bf22aa..740caa8 100644
--- a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -25,6 +25,7 @@
   public Integer _revisionNumber;
   public Integer _currentRevisionNumber;
   public String status;
+  public Boolean submittable;
 
   public RelatedChangeAndCommitInfo() {}
 
@@ -38,6 +39,7 @@
         .add("_revisionNumber", _revisionNumber)
         .add("_currentRevisionNumber", _currentRevisionNumber)
         .add("status", status)
+        .add("submittable", submittable)
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index 148d24a..613e48e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -36,4 +36,6 @@
    * {@link NotifyHandling#OWNER}
    */
   public boolean workInProgress;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 11999ab..8bfe468 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
@@ -117,11 +119,13 @@
     public List<FixSuggestionInfo> fixSuggestions;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput message(String msg) {
     message = msg != null && !msg.isEmpty() ? msg : null;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput patchSetLevelComment(String message) {
     Objects.requireNonNull(message);
     CommentInput comment = new CommentInput();
@@ -131,6 +135,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name, short value) {
     if (name == null || name.isEmpty()) {
       throw new IllegalArgumentException();
@@ -142,6 +147,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name, int value) {
     if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
       throw new IllegalArgumentException();
@@ -149,14 +155,22 @@
     return label(name, (short) value);
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name) {
     return label(name, (short) 1);
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput reviewer(String reviewer) {
-    return reviewer(reviewer, REVIEWER, false);
+    return reviewer(reviewer, REVIEWER, /* confirmed= */ false);
   }
 
+  @CanIgnoreReturnValue
+  public ReviewInput cc(String cc) {
+    return reviewer(cc, CC, /* confirmed= */ false);
+  }
+
+  @CanIgnoreReturnValue
   public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
     ReviewerInput input = new ReviewerInput();
     input.reviewer = reviewer;
@@ -169,6 +183,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput addUserToAttentionSet(String user, String reason) {
     AttentionSetInput input = new AttentionSetInput();
     input.user = user;
@@ -180,6 +195,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput removeUserFromAttentionSet(String user, String reason) {
     AttentionSetInput input = new AttentionSetInput();
     input.user = user;
@@ -191,17 +207,20 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput blockAutomaticAttentionSetRules() {
     ignoreAutomaticAttentionSetRules = true;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     ready = !workInProgress;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput setReady(boolean ready) {
     this.ready = ready;
     workInProgress = !ready;
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 1307516..73fc170 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -51,12 +53,6 @@
 
   ChangeInfo submit(SubmitInput in) throws RestApiException;
 
-  default BinaryResult submitPreview() throws RestApiException {
-    return submitPreview("zip");
-  }
-
-  BinaryResult submitPreview(String format) throws RestApiException;
-
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
   ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
@@ -123,8 +119,20 @@
    */
   EditInfo applyFix(String fixId) throws RestApiException;
 
+  /**
+   * Applies fix similar to {@code applyFix} method. Instead of using a fix stored in the server,
+   * this applies the fix provided in {@code ApplyProvidedFixInput}
+   *
+   * @param applyProvidedFixInput The fix(es) to apply to a new change edit.
+   * @throws RestApiException if the fix couldn't be applied.
+   */
+  EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException;
+
   Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException;
 
+  Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -152,6 +160,8 @@
 
   RelatedChangesInfo related() throws RestApiException;
 
+  RelatedChangesInfo related(EnumSet<GetRelatedOption> listOptions) throws RestApiException;
+
   /** Returns votes on the revision. */
   ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
 
@@ -319,11 +329,22 @@
     }
 
     @Override
+    public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -369,11 +390,6 @@
     }
 
     @Override
-    public BinaryResult submitPreview(String format) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -394,6 +410,11 @@
     }
 
     @Override
+    public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index e2cab4d..68a4e88 100644
--- a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,5 +16,6 @@
 
 /** Output options available for submitted_together requests. */
 public enum SubmittedTogetherOption {
-  NON_VISIBLE_CHANGES;
+  NON_VISIBLE_CHANGES,
+  TOPIC_CLOSURE;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index aaf69d9..aeefcd1 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.api.projects;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
 
 public class BranchInput {
   @DefaultInput public String revision;
   public String ref;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index 23849e4..5c47ac3 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -17,10 +17,13 @@
 import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
+/** See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for field documentation. */
 public class CommentLinkInfo {
   public String match;
   public String link;
-  public String html;
+  public String prefix;
+  public String suffix;
+  public String text;
   public Boolean enabled; // null means true
 
   public transient String name;
@@ -34,7 +37,9 @@
       CommentLinkInfo that = (CommentLinkInfo) o;
       return Objects.equals(this.match, that.match)
           && Objects.equals(this.link, that.link)
-          && Objects.equals(this.html, that.html)
+          && Objects.equals(this.prefix, that.prefix)
+          && Objects.equals(this.suffix, that.suffix)
+          && Objects.equals(this.text, that.text)
           && Objects.equals(this.enabled, that.enabled);
     }
     return false;
@@ -42,7 +47,7 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(match, link, html, enabled);
+    return Objects.hash(match, link, prefix, suffix, text, enabled);
   }
 
   @Override
@@ -51,7 +56,9 @@
         .add("name", name)
         .add("match", match)
         .add("link", link)
-        .add("html", html)
+        .add("prefix", prefix)
+        .add("suffix", suffix)
+        .add("text", text)
         .add("enabled", enabled)
         .toString();
   }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
index 3aad7e1..1c964a4 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
@@ -14,14 +14,22 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-/*
+/**
  * Input for a commentlink configuration on a project.
+ *
+ * <p>See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for additional details.
  */
 public class CommentLinkInput {
   /** A JavaScript regular expression to match positions to be replaced with a hyperlink. */
   public String match;
   /** The URL to direct the user to whenever the regular expression is matched. */
   public String link;
+  /** Text inserted before the link if the regular expression is matched. */
+  public String prefix;
+  /** Text inserted after the link if the regular expression is matched. */
+  public String suffix;
+  /** Text of the link. */
+  public String text;
   /** Whether the commentlink is enabled. */
   public Boolean enabled;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 3ba1277..1a51c15 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -40,6 +40,7 @@
   public InheritedBooleanInfo enableReviewerByEmail;
   public InheritedBooleanInfo matchAuthorToCommitterDate;
   public InheritedBooleanInfo rejectEmptyCommit;
+  public InheritedBooleanInfo skipAddingAuthorAndCommitterAsReviewers;
 
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   @Deprecated // Equivalent to defaultSubmitType.value
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 8005fc5..906fc4c 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -34,6 +34,7 @@
   public InheritableBoolean enableReviewerByEmail;
   public InheritableBoolean matchAuthorToCommitterDate;
   public InheritableBoolean rejectEmptyCommit;
+  public InheritableBoolean skipAddingAuthorAndCommitterAsReviewers;
   public String maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 9873995..f6408b6 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -22,9 +22,13 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Collection;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public interface ProjectApi {
   ProjectApi create() throws RestApiException;
@@ -51,6 +55,9 @@
 
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
+  Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+      throws RestApiException;
+
   ListRefsRequest<BranchInfo> branches();
 
   ListRefsRequest<TagInfo> tags();
@@ -221,6 +228,21 @@
 
   LabelApi label(String labelName) throws RestApiException;
 
+  ListSubmitRequirementsRequest submitRequirements() throws RestApiException;
+
+  abstract class ListSubmitRequirementsRequest {
+    protected boolean inherited;
+
+    public abstract List<SubmitRequirementInfo> get() throws RestApiException;
+
+    public ListSubmitRequirementsRequest withInherited(boolean inherited) {
+      this.inherited = inherited;
+      return this;
+    }
+  }
+
+  SubmitRequirementApi submitRequirement(String name) throws RestApiException;
+
   /**
    * Adds, updates and deletes label definitions in a batch.
    *
@@ -289,6 +311,12 @@
     }
 
     @Override
+    public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(DescriptionInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -409,11 +437,21 @@
     }
 
     @Override
+    public ListSubmitRequirementsRequest submitRequirements() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public LabelApi label(String labelName) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void labels(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
new file mode 100644
index 0000000..a6e79db
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface SubmitRequirementApi {
+  /** Create a new submit requirement. */
+  SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException;
+
+  /** Get existing submit requirement. */
+  SubmitRequirementInfo get() throws RestApiException;
+
+  /** Update existing submit requirement. */
+  SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException;
+
+  /** Delete existing submit requirement. */
+  void delete() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements SubmitRequirementApi {
+    @Override
+    public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/TagInfo.java b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
index a6269fe..61ea518 100644
--- a/java/com/google/gerrit/extensions/api/projects/TagInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
@@ -14,16 +14,22 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 public class TagInfo extends RefInfo {
   public String object;
   public String message;
   public GitPerson tagger;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
+
   public List<WebLinkInfo> webLinks;
 
   public TagInfo(
@@ -39,8 +45,22 @@
     this.created = created;
   }
 
+  @SuppressWarnings("JdkObsolete")
+  public TagInfo(
+      String ref,
+      String revision,
+      Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      @Nullable Instant created) {
+    this.ref = ref;
+    this.revision = revision;
+    this.canDelete = canDelete;
+    this.webLinks = webLinks;
+    this.created = created != null ? Timestamp.from(created) : null;
+  }
+
   public TagInfo(String ref, String revision, Boolean canDelete, List<WebLinkInfo> webLinks) {
-    this(ref, revision, canDelete, webLinks, null);
+    this(ref, revision, canDelete, webLinks, (Instant) null);
   }
 
   public TagInfo(
@@ -66,8 +86,24 @@
       String message,
       GitPerson tagger,
       Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      Instant created) {
+    this(ref, revision, canDelete, webLinks, created);
+    this.object = object;
+    this.message = message;
+    this.tagger = tagger;
+    this.webLinks = webLinks;
+  }
+
+  public TagInfo(
+      String ref,
+      String revision,
+      String object,
+      String message,
+      GitPerson tagger,
+      Boolean canDelete,
       List<WebLinkInfo> webLinks) {
-    this(ref, revision, object, message, tagger, canDelete, webLinks, null);
+    this(ref, revision, object, message, tagger, canDelete, webLinks, (Instant) null);
     this.object = object;
     this.message = message;
     this.tagger = tagger;
diff --git a/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
index e58e005..6240bba 100644
--- a/java/com/google/gerrit/extensions/client/ChangeKind.java
+++ b/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -29,5 +29,42 @@
   NO_CODE_CHANGE,
 
   /** Same tree, parent tree, same commit message. */
-  NO_CHANGE
+  NO_CHANGE;
+
+  public boolean matches(ChangeKind changeKind, boolean isMerge) {
+    switch (changeKind) {
+      case REWORK:
+        // REWORK inlcudes all other change kinds, since those are just more trivial cases of a
+        // rework
+        return true;
+      case TRIVIAL_REBASE:
+        return isTrivialRebase();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return isMergeFirstParentUpdate(isMerge);
+      case NO_CHANGE:
+        return this == NO_CHANGE;
+      case NO_CODE_CHANGE:
+        return isNoCodeChange();
+    }
+    throw new IllegalStateException("unexpected change kind: " + changeKind);
+  }
+
+  public boolean isNoCodeChange() {
+    // NO_CHANGE is a more trivial case of NO_CODE_CHANGE and hence matched as well
+    return this == NO_CHANGE || this == NO_CODE_CHANGE;
+  }
+
+  public boolean isTrivialRebase() {
+    // NO_CHANGE is a more trivial case of TRIVIAL_REBASE and hence matched as well
+    return this == NO_CHANGE || this == TRIVIAL_REBASE;
+  }
+
+  public boolean isMergeFirstParentUpdate(boolean isMerge) {
+    if (!isMerge) {
+      return false;
+    }
+
+    // NO_CHANGE is a more trivial case of MERGE_FIRST_PARENT_UPDATE and hence matched as well
+    return this == NO_CHANGE || this == MERGE_FIRST_PARENT_UPDATE;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 634992e..b8843d3 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.client;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Comparator;
 import java.util.Objects;
 
@@ -35,7 +36,11 @@
 
   public Range range;
   public String inReplyTo;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp updated;
+
   public String message;
 
   /**
@@ -44,6 +49,20 @@
    */
   public String commitId;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getUpdated() {
+    return updated.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setUpdated(Instant when) {
+    updated = Timestamp.from(when);
+  }
+
   public static class Range implements Comparable<Range> {
     private static final Comparator<Range> RANGE_COMPARATOR =
         Comparator.<Range>comparingInt(range -> range.startLine)
@@ -70,10 +89,10 @@
     public boolean equals(Object o) {
       if (o instanceof Range) {
         Range r = (Range) o;
-        return Objects.equals(startLine, r.startLine)
-            && Objects.equals(startCharacter, r.startCharacter)
-            && Objects.equals(endLine, r.endLine)
-            && Objects.equals(endCharacter, r.endCharacter);
+        return startLine == r.startLine
+            && startCharacter == r.startCharacter
+            && endLine == r.endLine
+            && endCharacter == r.endCharacter;
       }
       return false;
     }
@@ -110,6 +129,11 @@
     return 1;
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the extension API, hence we cannot change it
+  // without breaking the API. Hence suppress the EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index b26f435..1ee2cd8 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -22,9 +22,6 @@
   /** Default number of items to display per page. */
   public static final int DEFAULT_PAGESIZE = 25;
 
-  /** Valid choices for the page size. */
-  public static final int[] PAGESIZE_CHOICES = {10, 25, 50, 100};
-
   /** Preferred method to download a change. */
   public enum DownloadCommand {
     PULL,
@@ -105,6 +102,7 @@
   }
 
   public enum Theme {
+    AUTO,
     DARK,
     LIGHT
   }
@@ -152,6 +150,7 @@
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
+  public Boolean allowBrowserNotifications;
 
   public DateFormat getDateFormat() {
     if (dateFormat == null) {
@@ -192,7 +191,7 @@
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.downloadScheme = null;
-    p.theme = Theme.LIGHT;
+    p.theme = Theme.AUTO;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
@@ -210,6 +209,7 @@
     p.disableKeyboardShortcuts = false;
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
+    p.allowBrowserNotifications = true;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/Side.java b/java/com/google/gerrit/extensions/client/Side.java
index e077df2..a87b37a 100644
--- a/java/com/google/gerrit/extensions/client/Side.java
+++ b/java/com/google/gerrit/extensions/client/Side.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.gerrit.common.Nullable;
+
 public enum Side {
   PARENT,
   REVISION;
 
+  @Nullable
   public static Side fromShort(short s) {
     if (s <= 0) {
       return PARENT;
diff --git a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
index a2aeab2..a76a7f9 100644
--- a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Representation of a (detailed) account in the REST API.
@@ -27,9 +28,18 @@
  */
 public class AccountDetailInfo extends AccountInfo {
   /** The timestamp of when the account was registered. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp registeredOn;
 
   public AccountDetailInfo(Integer id) {
     super(id);
   }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setRegisteredOn(Instant registeredOn) {
+    this.registeredOn = Timestamp.from(registeredOn);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
index e3e0fc8..b51e195 100644
--- a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -57,10 +57,10 @@
   public boolean equals(Object o) {
     if (o instanceof AccountExternalIdInfo) {
       AccountExternalIdInfo a = (AccountExternalIdInfo) o;
-      return (Objects.equals(a.identity, identity))
-          && (Objects.equals(a.emailAddress, emailAddress))
-          && (Objects.equals(a.trusted, trusted))
-          && (Objects.equals(a.canDelete, canDelete));
+      return Objects.equals(a.identity, identity)
+          && Objects.equals(a.emailAddress, emailAddress)
+          && Objects.equals(a.trusted, trusted)
+          && Objects.equals(a.canDelete, canDelete);
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
new file mode 100644
index 0000000..cd28d83
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,//
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+/**
+ * Input containing fix definitions to apply the provided fix to the change, on the patchset
+ * specified by revision id.
+ */
+public class ApplyProvidedFixInput {
+  public ApplyProvidedFixInput() {}
+
+  public List<FixReplacementInfo> fixReplacementInfos;
+}
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index bf72e83..4519add 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -43,6 +44,8 @@
   public Integer value;
 
   /** The time and date describing when the approval was made. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
 
   /** Whether this vote was made after the change was submitted. */
@@ -62,10 +65,10 @@
 
   public ApprovalInfo(
       Integer id,
-      Integer value,
+      @Nullable Integer value,
       @Nullable VotingRangeInfo permittedVotingRange,
       @Nullable String tag,
-      Timestamp date) {
+      @Nullable Timestamp date) {
     super(id);
     this.value = value;
     this.permittedVotingRange = permittedVotingRange;
@@ -73,6 +76,28 @@
     this.tag = tag;
   }
 
+  public ApprovalInfo(
+      Integer id,
+      @Nullable Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      @Nullable Instant date) {
+    super(id);
+    this.value = value;
+    this.permittedVotingRange = permittedVotingRange;
+    this.tag = tag;
+    if (date != null) {
+      setDate(date);
+    }
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant date) {
+    this.date = Timestamp.from(date);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ApprovalInfo) {
@@ -88,6 +113,11 @@
   }
 
   @Override
+  public String toString() {
+    return super.toString() + ", value=" + this.value;
+  }
+
+  @Override
   public int hashCode() {
     return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
   }
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
index d34ba6d..81dbc88 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -28,8 +29,12 @@
 public class AttentionSetInfo {
   /** The user included in the attention set. */
   public AccountInfo account;
+
   /** The timestamp of the last update. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp lastUpdate;
+
   /** The human readable reason why the user was added. */
   public String reason;
 
@@ -51,6 +56,17 @@
     this.reasonAccount = reasonAccount;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public AttentionSetInfo(
+      AccountInfo account, Instant lastUpdate, String reason, @Nullable AccountInfo reasonAccount) {
+    this.account = account;
+    this.lastUpdate = Timestamp.from(lastUpdate);
+    this.reason = reason;
+    this.reasonAccount = reasonAccount;
+  }
+
   protected AttentionSetInfo() {}
 
   @Override
diff --git a/java/com/google/gerrit/extensions/common/BlameInfo.java b/java/com/google/gerrit/extensions/common/BlameInfo.java
index df3f373..6ee677e 100644
--- a/java/com/google/gerrit/extensions/common/BlameInfo.java
+++ b/java/com/google/gerrit/extensions/common/BlameInfo.java
@@ -28,7 +28,7 @@
     if (this == o) {
       return true;
     }
-    if (o == null || getClass() != o.getClass()) {
+    if (o == null || !(o instanceof BlameInfo)) {
       return false;
     }
     BlameInfo blameInfo = (BlameInfo) o;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 2bb3dd7..a865187 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -46,14 +47,20 @@
    */
   public Map<Integer, AttentionSetInfo> attentionSet;
 
+  public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
+
   public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
   public ChangeStatus status;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
   public Timestamp updated;
   public Timestamp submitted;
+
   public AccountInfo submitter;
   public Boolean starred;
   public Collection<String> stars;
@@ -98,6 +105,7 @@
   public Map<String, ActionInfo> actions;
   public Map<String, LabelInfo> labels;
   public Map<String, Collection<String>> permittedLabels;
+  public Map<String, Map<String, List<AccountInfo>>> removableLabels;
   public Collection<AccountInfo> removableReviewers;
   public Map<ReviewerState, Collection<AccountInfo>> reviewers;
   public Map<ReviewerState, Collection<AccountInfo>> pendingReviewers;
@@ -124,4 +132,47 @@
   public ChangeInfo(Map<String, RevisionInfo> revisions) {
     this.revisions = ImmutableMap.copyOf(revisions);
   }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getCreated() {
+    return created.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreated(Instant when) {
+    created = Timestamp.from(when);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getUpdated() {
+    return updated.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setUpdated(Instant when) {
+    updated = Timestamp.from(when);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getSubmitted() {
+    return submitted.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setSubmitted(Instant when, AccountInfo who) {
+    submitted = Timestamp.from(when);
+    submitter = who;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index ad112d3..51c35dc 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -21,6 +21,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
 import java.sql.Timestamp;
@@ -147,16 +148,19 @@
   }
 
   /** Returns {@code null} if nothing has been added to {@code oldCollection} */
+  @Nullable
   private static ImmutableList<?> getAddedForCollection(
-      Collection<?> oldCollection, Collection<?> newCollection) {
-    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditionsForCollection(oldCollection, newCollection);
     return notInOldCollection.isEmpty() ? null : notInOldCollection;
   }
 
-  private static ImmutableList<Object> getAdditions(
-      Collection<?> oldCollection, Collection<?> newCollection) {
-    if (oldCollection == null)
-      return newCollection != null ? ImmutableList.copyOf(newCollection) : null;
+  @Nullable
+  private static ImmutableList<Object> getAdditionsForCollection(
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    if (oldCollection == null) {
+      return ImmutableList.copyOf(newCollection);
+    }
 
     Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
     oldCollection.forEach(
@@ -169,7 +173,19 @@
   }
 
   /** Returns {@code null} if nothing has been added to {@code oldMap} */
-  private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+  @Nullable
+  private static ImmutableMap<Object, Object> getAddedForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    ImmutableMap<Object, Object> notInOldMap = getAdditionsForMap(oldMap, newMap);
+    return notInOldMap.isEmpty() ? null : notInOldMap;
+  }
+
+  @Nullable
+  private static ImmutableMap<Object, Object> getAdditionsForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    if (oldMap == null) {
+      return ImmutableMap.copyOf(newMap);
+    }
     ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
     for (Map.Entry<?, ?> entry : newMap.entrySet()) {
       Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
@@ -177,8 +193,7 @@
         additionsBuilder.put(entry.getKey(), added);
       }
     }
-    ImmutableMap<Object, Object> additions = additionsBuilder.build();
-    return additions.isEmpty() ? null : additions;
+    return additionsBuilder.build();
   }
 
   private static Object get(Field field, Object obj) {
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index ea12ef1..55a5883 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
@@ -35,6 +36,7 @@
   public Boolean newBranch;
   public Map<String, String> validationOptions;
   public MergeInput merge;
+  public ApplyPatchInput patch;
 
   public AccountInput author;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index c1cb1627..51fe57c 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.collect.Iterables;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.Objects;
 
@@ -24,7 +26,11 @@
   public String tag;
   public AccountInfo author;
   public AccountInfo realAuthor;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
+
   public String message;
   public Collection<AccountInfo> accountsInMessage;
   public Integer _revisionNumber;
@@ -35,6 +41,13 @@
     this.message = message;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant when) {
+    date = Timestamp.from(when);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ChangeMessageInfo) {
@@ -45,7 +58,10 @@
           && Objects.equals(realAuthor, cmi.realAuthor)
           && Objects.equals(date, cmi.date)
           && Objects.equals(message, cmi.message)
-          && Objects.equals(accountsInMessage, cmi.accountsInMessage)
+          && ((accountsInMessage == null && cmi.accountsInMessage == null)
+              || (accountsInMessage != null
+                  && cmi.accountsInMessage != null
+                  && Iterables.elementsEqual(accountsInMessage, cmi.accountsInMessage)))
           && Objects.equals(_revisionNumber, cmi._revisionNumber);
     }
     return false;
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index c732663..9526fbb 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -18,6 +18,8 @@
 
 public class FileInfo {
   public Character status;
+  public Integer oldMode;
+  public Integer newMode;
   public Boolean binary;
   public String oldPath;
   public Integer linesInserted;
diff --git a/java/com/google/gerrit/extensions/common/GitPerson.java b/java/com/google/gerrit/extensions/common/GitPerson.java
index 8ed919e..df3e488 100644
--- a/java/com/google/gerrit/extensions/common/GitPerson.java
+++ b/java/com/google/gerrit/extensions/common/GitPerson.java
@@ -15,14 +15,26 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 public class GitPerson {
   public String name;
   public String email;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
+
   public int tz;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant when) {
+    date = Timestamp.from(when);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof GitPerson)) {
diff --git a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
index 711337a..9a13713 100644
--- a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 public abstract class GroupAuditEventInfo {
@@ -27,25 +29,62 @@
 
   public Type type;
   public AccountInfo user;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public static UserMemberAuditEventInfo createAddUserEvent(
       AccountInfo user, Timestamp date, AccountInfo member) {
-    return new UserMemberAuditEventInfo(Type.ADD_USER, user, Optional.of(date), member);
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date.toInstant(), member);
+  }
+
+  public static UserMemberAuditEventInfo createAddUserEvent(
+      AccountInfo user, Instant date, AccountInfo member) {
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date, member);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public static UserMemberAuditEventInfo createRemoveUserEvent(
+      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+    return new UserMemberAuditEventInfo(
+        Type.REMOVE_USER, user, date.map(Timestamp::toInstant).orElse(null), member);
   }
 
   public static UserMemberAuditEventInfo createRemoveUserEvent(
-      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+      AccountInfo user, @Nullable Instant date, AccountInfo member) {
     return new UserMemberAuditEventInfo(Type.REMOVE_USER, user, date, member);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public static GroupMemberAuditEventInfo createAddGroupEvent(
       AccountInfo user, Timestamp date, GroupInfo member) {
-    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, Optional.of(date), member);
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date.toInstant(), member);
+  }
+
+  public static GroupMemberAuditEventInfo createAddGroupEvent(
+      AccountInfo user, Instant date, GroupInfo member) {
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date, member);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public static GroupMemberAuditEventInfo createRemoveGroupEvent(
+      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+    return new GroupMemberAuditEventInfo(
+        Type.REMOVE_GROUP, user, date.map(Timestamp::toInstant).orElse(null), member);
   }
 
   public static GroupMemberAuditEventInfo createRemoveGroupEvent(
-      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+      AccountInfo user, @Nullable Instant date, GroupInfo member) {
     return new GroupMemberAuditEventInfo(Type.REMOVE_GROUP, user, date, member);
   }
 
@@ -55,11 +94,20 @@
     this.date = date.orElse(null);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  protected GroupAuditEventInfo(Type type, AccountInfo user, @Nullable Instant date) {
+    this.type = type;
+    this.user = user;
+    this.date = date != null ? Timestamp.from(date) : null;
+  }
+
   public static class UserMemberAuditEventInfo extends GroupAuditEventInfo {
     public AccountInfo member;
 
     private UserMemberAuditEventInfo(
-        Type type, AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+        Type type, AccountInfo user, @Nullable Instant date, AccountInfo member) {
       super(type, user, date);
       this.member = member;
     }
@@ -69,7 +117,7 @@
     public GroupInfo member;
 
     private GroupMemberAuditEventInfo(
-        Type type, AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+        Type type, AccountInfo user, @Nullable Instant date, GroupInfo member) {
       super(type, user, date);
       this.member = member;
     }
diff --git a/java/com/google/gerrit/extensions/common/GroupInfo.java b/java/com/google/gerrit/extensions/common/GroupInfo.java
index b21475c..edbaa01 100644
--- a/java/com/google/gerrit/extensions/common/GroupInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 public class GroupInfo extends GroupBaseInfo {
@@ -26,10 +27,28 @@
   public Integer groupId;
   public String owner;
   public String ownerId;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp createdOn;
+
   public Boolean _moreGroups;
 
   // These fields are only supplied for internal groups, and only if requested.
   public List<AccountInfo> members;
   public List<GroupInfo> includes;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getCreatedOn() {
+    return createdOn.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreatedOn(Instant when) {
+    createdOn = Timestamp.from(when);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index 6f733d6..975061b 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -19,22 +19,14 @@
 
 public class LabelDefinitionInfo {
   public String name;
+  public String description;
   public String projectName;
   public String function;
   public Map<String, String> values;
   public short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 38b76c1..277fccd 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -19,22 +19,14 @@
 
 public class LabelDefinitionInput extends InputWithCommitMessage {
   public String name;
+  public String description;
   public String function;
   public Map<String, String> values;
   public Short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
   public Boolean unsetCopyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
index 44bcdaf..cdd3b18 100644
--- a/java/com/google/gerrit/extensions/common/LabelInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelInfo.java
@@ -27,6 +27,7 @@
 
   public Map<String, String> values;
 
+  public String description;
   public Short value;
   public Short defaultValue;
   public Boolean optional;
diff --git a/java/com/google/gerrit/extensions/common/RebaseChainInfo.java b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
new file mode 100644
index 0000000..b327007
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class RebaseChainInfo {
+  public List<ChangeInfo> rebasedChanges;
+  /**
+   * Whether any of the changes contain conflicts.
+   *
+   * <p>If {@code true}, some of the rebased changes are marked with conflicts.
+   */
+  public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index 37e1ceb..36682f6 100644
--- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -16,14 +16,31 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 public class ReviewerUpdateInfo {
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp updated;
+
   public AccountInfo updatedBy;
   public AccountInfo reviewer;
   public ReviewerState state;
 
+  public ReviewerUpdateInfo() {}
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public ReviewerUpdateInfo(
+      Instant updated, AccountInfo updatedBy, AccountInfo reviewer, ReviewerState state) {
+    this.updated = Timestamp.from(updated);
+    this.updatedBy = updatedBy;
+    this.reviewer = reviewer;
+    this.state = state;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ReviewerUpdateInfo) {
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index f710ab7..7c52c8c 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Objects;
 
@@ -25,7 +26,11 @@
   public transient boolean isCurrent;
   public ChangeKind kind;
   public int _number;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
+
   public AccountInfo uploader;
   public String ref;
   public Map<String, FetchInfo> fetch;
@@ -51,6 +56,13 @@
     this.uploader = uploader;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreated(Instant date) {
+    this.created = Timestamp.from(date);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof RevisionInfo) {
diff --git a/java/com/google/gerrit/extensions/common/ServerInfo.java b/java/com/google/gerrit/extensions/common/ServerInfo.java
index bc7fcfd..ce65240 100644
--- a/java/com/google/gerrit/extensions/common/ServerInfo.java
+++ b/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.List;
+
 /** API response containing values from {@code gerrit.config} as nested objects. */
 public class ServerInfo {
   public AccountsInfo accounts;
@@ -28,4 +30,5 @@
   public UserConfigInfo user;
   public ReceiveInfo receive;
   public String defaultTheme;
+  public List<String> submitRequirementDashboardColumns;
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
index 09c9841..d957733 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 /** API response containing a {@link com.google.gerrit.entities.SubmitRecord} entity. */
 public class SubmitRecordInfo {
@@ -36,8 +37,27 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public AccountInfo appliedBy;
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof Label)) {
+        return false;
+      }
+      Label that = (Label) o;
+      return Objects.equals(label, that.label)
+          && status == that.status
+          && Objects.equals(appliedBy, that.appliedBy);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(label, status, appliedBy);
+    }
   }
 
   public String ruleName;
@@ -45,4 +65,25 @@
   public List<Label> labels;
   public List<LegacySubmitRequirementInfo> requirements;
   public String errorMessage;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRecordInfo)) {
+      return false;
+    }
+    SubmitRecordInfo that = (SubmitRecordInfo) o;
+    return Objects.equals(ruleName, that.ruleName)
+        && status == that.status
+        && Objects.equals(labels, that.labels)
+        && Objects.equals(requirements, that.requirements)
+        && Objects.equals(errorMessage, that.errorMessage);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(ruleName, status, labels, requirements, errorMessage);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index 4d1fce2..742d0c8 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
-/** Result of evaluating a single submit requirement expression. */
+/**
+ * Result of evaluating a single submit requirement expression. This API entity is populated from
+ * {@link com.google.gerrit.entities.SubmitRequirementExpressionResult}.
+ */
 public class SubmitRequirementExpressionInfo {
 
   /** Submit requirement expression as a String. */
@@ -25,6 +29,9 @@
   /** A boolean indicating if the expression is fulfilled on a change. */
   public boolean fulfilled;
 
+  /** A status indicating if the expression is fulfilled, non-fulfilled or not evaluated. */
+  public Status status;
+
   /**
    * A list of all atoms that are passing, for example query "branch:refs/heads/foo and project:bar"
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
@@ -36,4 +43,49 @@
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
    */
   public List<String> failingAtoms;
+
+  /**
+   * Optional error message. Contains an explanation of why the submit requirement expression failed
+   * during its evaluation.
+   */
+  public String errorMessage;
+
+  /**
+   * Values in this enum should match with values in {@link
+   * com.google.gerrit.entities.SubmitRequirementExpressionResult.Status}.
+   */
+  public enum Status {
+    /** Expression was evaluated and the result was true. */
+    PASS,
+
+    /** Expression was evaluated and the result was false. */
+    FAIL,
+
+    /** An error occurred while evaluating the expression. */
+    ERROR,
+
+    /** Expression was not evaluated. */
+    NOT_EVALUATED
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRequirementExpressionInfo)) {
+      return false;
+    }
+    SubmitRequirementExpressionInfo that = (SubmitRequirementExpressionInfo) o;
+    return fulfilled == that.fulfilled
+        && Objects.equals(expression, that.expression)
+        && Objects.equals(passingAtoms, that.passingAtoms)
+        && Objects.equals(failingAtoms, that.failingAtoms)
+        && Objects.equals(errorMessage, that.errorMessage);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(expression, fulfilled, passingAtoms, failingAtoms, errorMessage);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
new file mode 100644
index 0000000..9347e7e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 SubmitRequirementInfo {
+  /** Name of the submit requirement. */
+  public String name;
+
+  /** Description of the submit requirement. */
+  public String description;
+
+  /**
+   * Expression string to be evaluated on a change. Decides if this submit requirement is applicable
+   * on the given change.
+   */
+  public String applicabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change.
+   */
+  public String submittabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change regardless of the evaluation of the {@link
+   * #submittabilityExpression}.
+   */
+  public String overrideExpression;
+
+  /** Boolean indicating if this submit requirement can be overridden in child projects. */
+  public boolean allowOverrideInChildProjects;
+}
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
index 3d50f13..cf0d53c 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
+import java.util.Objects;
+
 /** Result of evaluating a submit requirement on a change. */
 public class SubmitRequirementResultInfo {
   public enum Status {
@@ -41,7 +44,13 @@
      * Any of the applicability, submittability or override expressions contain invalid syntax and
      * are not parsable.
      */
-    ERROR
+    ERROR,
+
+    /**
+     * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
+     * %submit option.
+     */
+    FORCED
   }
 
   /** Submit requirement name. */
@@ -57,11 +66,41 @@
   public boolean isLegacy;
 
   /** Result of evaluating the applicability expression. */
-  public SubmitRequirementExpressionInfo applicabilityExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo applicabilityExpressionResult;
 
   /** Result of evaluating the submittability expression. */
-  public SubmitRequirementExpressionInfo submittabilityExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo submittabilityExpressionResult;
 
   /** Result of evaluating the override expression. */
-  public SubmitRequirementExpressionInfo overrideExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo overrideExpressionResult;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRequirementResultInfo)) {
+      return false;
+    }
+    SubmitRequirementResultInfo that = (SubmitRequirementResultInfo) o;
+    return isLegacy == that.isLegacy
+        && Objects.equals(name, that.name)
+        && Objects.equals(description, that.description)
+        && status == that.status
+        && Objects.equals(applicabilityExpressionResult, that.applicabilityExpressionResult)
+        && Objects.equals(submittabilityExpressionResult, that.submittabilityExpressionResult)
+        && Objects.equals(overrideExpressionResult, that.overrideExpressionResult);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        name,
+        description,
+        status,
+        isLegacy,
+        applicabilityExpressionResult,
+        submittabilityExpressionResult,
+        overrideExpressionResult);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
index d011d5d..180a94691 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
@@ -45,6 +45,16 @@
     return check("linesDeleted").that(fileInfo.linesDeleted);
   }
 
+  public IntegerSubject oldMode() {
+    isNotNull();
+    return check("oldMode").that(fileInfo.oldMode);
+  }
+
+  public IntegerSubject newMode() {
+    isNotNull();
+    return check("newMode").that(fileInfo.newMode);
+  }
+
   public ComparableSubject<Character> status() {
     isNotNull();
     return check("status").that(fileInfo.status);
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index d827d5d..f75ec66 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -24,7 +24,6 @@
 import com.google.common.truth.Subject;
 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 {
@@ -71,11 +70,16 @@
     tz().isEqualTo(other.tz);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public void matches(PersonIdent ident) {
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(new Date(gitPerson.date.getTime())).isEqualTo(ident.getWhen());
+    check("roundedDate()")
+        .that(gitPerson.date.getTime())
+        .isEqualTo(ident.getWhenAsInstant().toEpochMilli());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/events/AttentionSetListener.java b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
new file mode 100644
index 0000000..ada30ce
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+import java.util.Set;
+
+/** Notified whenever the attention set is changed. */
+@ExtensionPoint
+public interface AttentionSetListener {
+  interface Event extends ChangeEvent {
+
+    /**
+     * Returns the users added to the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersAdded();
+
+    /**
+     * Returns the users removed from the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersRemoved();
+  }
+
+  /**
+   * This function will be called when the attention set changes
+   *
+   * @param event The event that changed the attention set
+   */
+  void onAttentionSetChanged(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/ChangeEvent.java b/java/com/google/gerrit/extensions/events/ChangeEvent.java
index def75b7..6542d8e 100644
--- a/java/com/google/gerrit/extensions/events/ChangeEvent.java
+++ b/java/com/google/gerrit/extensions/events/ChangeEvent.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Interface to be extended by Events with a Change. */
 public interface ChangeEvent extends GerritEvent {
@@ -29,5 +29,5 @@
 
   AccountInfo getWho();
 
-  Timestamp getWhen();
+  Instant getWhen();
 }
diff --git a/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
new file mode 100644
index 0000000..3d638c8
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import java.util.Set;
+
+/** Notified when one or more references are modified. */
+@ExtensionPoint
+public interface GitBatchRefUpdateListener {
+  interface Event extends ProjectEvent {
+    Set<UpdatedRef> getUpdatedRefs();
+
+    Set<String> getRefNames();
+
+    /** The updater, could be null if it's the server. */
+    @Nullable
+    AccountInfo getUpdater();
+  }
+
+  interface UpdatedRef {
+    public String getRefName();
+
+    public String getOldObjectId();
+
+    public String getNewObjectId();
+
+    public boolean isCreate();
+
+    public boolean isDelete();
+
+    public boolean isNonFastForward();
+  }
+
+  void onGitBatchRefUpdate(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
index bf922f8..0fec0f0 100644
--- a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
+++ b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
@@ -21,18 +21,7 @@
 /** Notified when one or more references are modified. */
 @ExtensionPoint
 public interface GitReferenceUpdatedListener {
-  interface Event extends ProjectEvent {
-    String getRefName();
-
-    String getOldObjectId();
-
-    String getNewObjectId();
-
-    boolean isCreate();
-
-    boolean isDelete();
-
-    boolean isNonFastForward();
+  interface Event extends ProjectEvent, GitBatchRefUpdateListener.UpdatedRef {
     /** The updater, could be null if it's the server. */
     @Nullable
     AccountInfo getUpdater();
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
index d8dd1f9..7ed7077 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -39,6 +40,7 @@
     return new DynamicItem<>(key, find(injector, type), PluginName.GERRIT);
   }
 
+  @Nullable
   private static <T> Provider<T> find(Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     if (bindings != null && bindings.size() == 1) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicMap.java b/java/com/google/gerrit/extensions/registration/DynamicMap.java
index 48b1279..6fd2c03 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -24,8 +24,8 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
@@ -116,14 +116,14 @@
   /**
    * Get the names of all running plugins supplying this type.
    *
-   * @return sorted set of active plugins that supply at least one item.
+   * @return navigatable set of active plugins that supply at least one item.
    */
-  public SortedSet<String> plugins() {
-    SortedSet<String> r = new TreeSet<>();
+  public NavigableSet<String> plugins() {
+    NavigableSet<String> r = new TreeSet<>();
     for (NamePair p : items.keySet()) {
       r.add(p.pluginName);
     }
-    return Collections.unmodifiableSortedSet(r);
+    return Collections.unmodifiableNavigableSet(r);
   }
 
   /**
@@ -132,21 +132,21 @@
    * @param pluginName name of the plugin.
    * @return items exported by a plugin, keyed by the export name.
    */
-  public SortedMap<String, Provider<T>> byPlugin(String pluginName) {
-    SortedMap<String, Provider<T>> r = new TreeMap<>();
+  public NavigableMap<String, Provider<T>> byPlugin(String pluginName) {
+    NavigableMap<String, Provider<T>> r = new TreeMap<>();
     for (Map.Entry<NamePair, Provider<T>> e : items.entrySet()) {
       if (e.getKey().pluginName.equals(pluginName)) {
         r.put(e.getKey().exportName, e.getValue());
       }
     }
-    return Collections.unmodifiableSortedMap(r);
+    return Collections.unmodifiableNavigableMap(r);
   }
 
   /** Iterate through all entries in an undefined order. */
   @Override
   public Iterator<Extension<T>> iterator() {
     final Iterator<Map.Entry<NamePair, Provider<T>>> i = items.entrySet().iterator();
-    return new Iterator<Extension<T>>() {
+    return new Iterator<>() {
       @Override
       public boolean hasNext() {
         return i.hasNext();
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index b2e871e..6dc8c6a 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -153,7 +154,7 @@
   @Override
   public Iterator<T> iterator() {
     Iterator<Extension<T>> entryIterator = entries().iterator();
-    return new Iterator<T>() {
+    return new Iterator<>() {
       @Override
       public boolean hasNext() {
         return entryIterator.hasNext();
@@ -170,7 +171,7 @@
   public Iterable<Extension<T>> entries() {
     final Iterator<AtomicReference<Extension<T>>> itr = items.iterator();
     return () ->
-        new Iterator<Extension<T>>() {
+        new Iterator<>() {
           private Extension<T> next;
 
           @Override
@@ -313,6 +314,7 @@
       return key;
     }
 
+    @Nullable
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       Extension<T> n = new Extension<>(item.getPluginName(), newItem);
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
index 832933b..d8999e3 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.collect.ImmutableList;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -38,11 +38,12 @@
     return new DynamicSet<>(find(injector, type));
   }
 
-  private static <T> List<AtomicReference<Extension<T>>> find(Injector src, TypeLiteral<T> type) {
+  private static <T> ImmutableList<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();
+      return ImmutableList.of();
     }
     List<AtomicReference<Extension<T>>> r = new ArrayList<>(cnt);
     for (Binding<T> b : bindings) {
@@ -50,6 +51,6 @@
         r.add(new AtomicReference<>(new Extension<>(PluginName.GERRIT, b.getProvider())));
       }
     }
-    return r;
+    return ImmutableList.copyOf(r);
   }
 }
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index fb520b4..67fc068 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -79,6 +80,7 @@
       return key;
     }
 
+    @Nullable
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       if (items.replace(np, item, newItem)) {
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index fd31fcd..5b528cb 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
@@ -80,10 +81,10 @@
     return Collections.unmodifiableMap(m);
   }
 
-  public static List<RegistrationHandle> attachItems(
+  public static ImmutableList<RegistrationHandle> attachItems(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicItem<?>> items) {
     if (src == null || items == null || items.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -103,13 +104,13 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
-  public static List<RegistrationHandle> attachSets(
+  public static ImmutableList<RegistrationHandle> attachSets(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
     if (src == null || sets == null || sets.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -131,13 +132,13 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
-  public static List<RegistrationHandle> attachMaps(
+  public static ImmutableList<RegistrationHandle> attachMaps(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
     if (src == null || maps == null || maps.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -160,7 +161,7 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
   public static LifecycleListener registerInParentInjectors() {
diff --git a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
index 86b821b..1b88b1a 100644
--- a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
+++ b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
@@ -25,7 +25,7 @@
 
   /**
    * @param msg message to return to the client describing the error.
-   * @param cause cause of this exception.
+   * @param cause original cause of the failed precondition.
    */
   public PreconditionFailedException(String msg, Throwable cause) {
     super(msg, cause);
diff --git a/java/com/google/gerrit/extensions/restapi/Url.java b/java/com/google/gerrit/extensions/restapi/Url.java
index 9c69376..09def84 100644
--- a/java/com/google/gerrit/extensions/restapi/Url.java
+++ b/java/com/google/gerrit/extensions/restapi/Url.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
@@ -40,6 +41,7 @@
    * @param component a string containing text to encode.
    * @return a string with all invalid URL characters escaped.
    */
+  @Nullable
   public static String encode(String component) {
     if (component != null) {
       try {
@@ -52,6 +54,7 @@
   }
 
   /** Decode a URL encoded string, e.g. from {@code "%2F"} to {@code "/"}. */
+  @Nullable
   public static String decode(String str) {
     if (str != null) {
       try {
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidationContext.java b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
index db08058..2c9d8f2 100644
--- a/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
+++ b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
@@ -34,7 +34,10 @@
   /** Returns the project the comment is being added to. */
   public abstract String getProject();
 
-  public static CommentValidationContext create(int changeId, String project) {
-    return new AutoValue_CommentValidationContext(changeId, project);
+  /** Returns the ref name the comment is being added to. */
+  public abstract String getRefName();
+
+  public static CommentValidationContext create(int changeId, String project, String refName) {
+    return new AutoValue_CommentValidationContext(changeId, project, refName);
   }
 }
diff --git a/java/com/google/gerrit/extensions/webui/FileWebLink.java b/java/com/google/gerrit/extensions/webui/FileWebLink.java
index c03d606..dc386b3 100644
--- a/java/com/google/gerrit/extensions/webui/FileWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/FileWebLink.java
@@ -32,8 +32,9 @@
    *
    * @param projectName Name of the project
    * @param revision Name of the revision (e.g. branch or commit ID)
+   * @param hash SHA-1 of the commit
    * @param fileName Name of the file
    * @return WebLinkInfo that links to project in external service, null if there should be no link.
    */
-  WebLinkInfo getFileWebLink(String projectName, String revision, String fileName);
+  WebLinkInfo getFileWebLink(String projectName, String revision, String hash, String fileName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 0e8e28e..74bccbd 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -37,6 +37,34 @@
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
+  @Deprecated
   WebLinkInfo getPatchSetWebLink(
       String projectName, String commit, String commitMessage, String branchName);
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
+   *
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
+   * @param changeKey the changeID for this change
+   * @return WebLinkInfo that links to patch set in external service, null if there should be no
+   *     link.
+   */
+  default WebLinkInfo getPatchSetWebLink(
+      String projectName,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey) {
+    return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
+  }
 }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 9f804c4..b2173c4 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
@@ -13,6 +14,7 @@
         "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-factory",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index c3dec61..874f1dc 100644
--- a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -14,28 +14,24 @@
 
 package com.google.gerrit.gpg;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
+@AutoFactory
 public class GerritPushCertificateChecker extends PushCertificateChecker {
-  public interface Factory {
-    GerritPushCertificateChecker create(IdentifiedUser expectedUser);
-  }
-
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @Inject
   GerritPushCertificateChecker(
-      GerritPublicKeyChecker.Factory keyCheckerFactory,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      @Assisted IdentifiedUser expectedUser) {
+      @Provided GerritPublicKeyChecker.Factory keyCheckerFactory,
+      @Provided GitRepositoryManager repoManager,
+      @Provided AllUsersName allUsers,
+      IdentifiedUser expectedUser) {
     super(keyCheckerFactory.create().setExpectedUser(expectedUser));
     this.repoManager = repoManager;
     this.allUsers = allUsers;
diff --git a/java/com/google/gerrit/gpg/GpgModule.java b/java/com/google/gerrit/gpg/GpgModule.java
index 45c1ab5..623b5f0 100644
--- a/java/com/google/gerrit/gpg/GpgModule.java
+++ b/java/com/google/gerrit/gpg/GpgModule.java
@@ -42,7 +42,6 @@
     }
     if (enableSignedPush) {
       install(new SignedPushModule());
-      factory(GerritPushCertificateChecker.Factory.class);
     }
     install(new GpgApiModule(enableSignedPush && configEditGpgKeys));
   }
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 27530e7..5347398 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -30,11 +30,12 @@
 import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -62,7 +63,7 @@
   private PublicKeyStore store;
   private Map<Long, Fingerprint> trusted;
   private int maxTrustDepth;
-  private Date effectiveTime = new Date();
+  private Instant effectiveTime = Instant.now();
 
   /**
    * Enable web-of-trust checks.
@@ -111,12 +112,12 @@
    * @param effectiveTime effective time.
    * @return a reference to this object.
    */
-  public PublicKeyChecker setEffectiveTime(Date effectiveTime) {
+  public PublicKeyChecker setEffectiveTime(Instant effectiveTime) {
     this.effectiveTime = effectiveTime;
     return this;
   }
 
-  protected Date getEffectiveTime() {
+  protected Instant getEffectiveTime() {
     return effectiveTime;
   }
 
@@ -183,13 +184,13 @@
     return CheckResult.create(status, problems);
   }
 
-  private CheckResult checkBasic(PGPPublicKey key, Date now) {
+  private CheckResult checkBasic(PGPPublicKey key, Instant now) {
     List<String> problems = new ArrayList<>(2);
     gatherRevocationProblems(key, now, problems);
 
     long validMs = key.getValidSeconds() * 1000;
     if (validMs != 0) {
-      long msSinceCreation = now.getTime() - key.getCreationTime().getTime();
+      long msSinceCreation = now.toEpochMilli() - getCreationTime(key).toEpochMilli();
       if (msSinceCreation > validMs) {
         problems.add("Key is expired");
       }
@@ -197,7 +198,7 @@
     return CheckResult.create(problems);
   }
 
-  private void gatherRevocationProblems(PGPPublicKey key, Date now, List<String> problems) {
+  private void gatherRevocationProblems(PGPPublicKey key, Instant now, List<String> problems) {
     try {
       List<PGPSignature> revocations = new ArrayList<>();
       Map<Long, RevocationKey> revokers = new HashMap<>();
@@ -216,7 +217,7 @@
   }
 
   private static boolean isRevocationValid(
-      PGPSignature revocation, RevocationReason reason, Date now) {
+      PGPSignature revocation, RevocationReason reason, Instant now) {
     // RFC4880 states:
     // "If a key has been revoked because of a compromise, all signatures
     // created by that key are suspect. However, if it was merely superseded or
@@ -226,11 +227,15 @@
     // consider the revocation reason and timestamp when checking whether a
     // signature (data or certification) is valid.
     return reason.getRevocationReason() == KEY_COMPROMISED
-        || revocation.getCreationTime().before(now);
+        || PushCertificateChecker.getCreationTime(revocation).isBefore(now);
   }
 
+  @Nullable
   private PGPSignature scanRevocations(
-      PGPPublicKey key, Date now, List<PGPSignature> revocations, Map<Long, RevocationKey> revokers)
+      PGPPublicKey key,
+      Instant now,
+      List<PGPSignature> revocations,
+      Map<Long, RevocationKey> revokers)
       throws PGPException {
     @SuppressWarnings("unchecked")
     Iterator<PGPSignature> allSigs = key.getSignatures();
@@ -261,6 +266,7 @@
     return null;
   }
 
+  @Nullable
   private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
     if (sig.getKeyID() != key.getKeyID()) {
       return null;
@@ -305,7 +311,7 @@
       if (rk.getAlgorithm() != revoker.getAlgorithm()) {
         continue;
       }
-      if (!checkBasic(rk, revocation.getCreationTime()).isOk()) {
+      if (!checkBasic(rk, PushCertificateChecker.getCreationTime(revocation)).isOk()) {
         // Revoker's key was expired or revoked at time of revocation, so the
         // revocation is invalid.
         continue;
@@ -317,6 +323,7 @@
     }
   }
 
+  @Nullable
   private static RevocationReason getRevocationReason(PGPSignature sig) {
     if (sig.getSignatureType() != KEY_REVOCATION) {
       throw new IllegalArgumentException(
@@ -422,6 +429,7 @@
     return CheckResult.create(OK, problems);
   }
 
+  @Nullable
   private static PGPPublicKey getSigner(
       PublicKeyStore store,
       PGPSignature sig,
@@ -452,6 +460,7 @@
     }
   }
 
+  @Nullable
   private String checkTrustSubpacket(PGPSignature sig, int depth) {
     SignatureSubpacket trustSub =
         sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
@@ -469,4 +478,9 @@
     }
     return null;
   }
+
+  @SuppressWarnings("JdkObsolete")
+  private static Instant getCreationTime(PGPPublicKey key) {
+    return key.getCreationTime().toInstant();
+  }
 }
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 2cce480..d167ac8 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -21,6 +21,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
@@ -92,6 +93,7 @@
    *     null} if none was found.
    * @throws PGPException if an error occurred verifying the signature.
    */
+  @Nullable
   public static PGPPublicKey getSigner(
       Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException {
     for (PGPPublicKeyRing kr : keyRings) {
@@ -126,6 +128,7 @@
    *     {@code null} if none was found.
    * @throws PGPException if an error occurred verifying the certification.
    */
+  @Nullable
   public static PGPPublicKey getSigner(
       Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, String userId, PGPPublicKey key)
       throws PGPException {
@@ -210,6 +213,7 @@
    * @throws PGPException if an error occurred parsing the key data.
    * @throws IOException if an error occurred reading the repository data.
    */
+  @Nullable
   public PGPPublicKeyRing get(byte[] fingerprint) throws PGPException, IOException {
     List<PGPPublicKeyRing> keyRings = get(Fingerprint.getId(fingerprint), fingerprint);
     return !keyRings.isEmpty() ? keyRings.get(0) : null;
diff --git a/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 36a4af7..b9ff50b 100644
--- a/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -22,9 +22,11 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import org.bouncycastle.bcpg.ArmoredInputStream;
@@ -106,7 +108,7 @@
       }
     } catch (PGPException | IOException e) {
       String msg = "Internal error checking push certificate";
-      logger.atSevere().withCause(e).log(msg);
+      logger.atSevere().withCause(e).log("%s", msg);
       results.add(CheckResult.bad(msg));
     }
 
@@ -175,6 +177,7 @@
     return CheckResult.ok();
   }
 
+  @Nullable
   private PGPSignature readSignature(PushCertificate cert) throws IOException {
     ArmoredInputStream in =
         new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(cert.getSignature())));
@@ -205,7 +208,7 @@
           null, CheckResult.bad("Signature by " + keyIdToString(sig.getKeyID()) + " is not valid"));
     }
     CheckResult result =
-        publicKeyChecker.setStore(store).setEffectiveTime(sig.getCreationTime()).check(signer);
+        publicKeyChecker.setStore(store).setEffectiveTime(getCreationTime(sig)).check(signer);
     if (!result.getProblems().isEmpty()) {
       StringBuilder err =
           new StringBuilder("Invalid public key ")
@@ -216,4 +219,9 @@
     }
     return new Result(signer, result);
   }
+
+  @SuppressWarnings("JdkObsolete")
+  public static Instant getCreationTime(PGPSignature signature) {
+    return signature.getCreationTime().toInstant();
+  }
 }
diff --git a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
index 21a5b6e..abc51c2 100644
--- a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
+++ b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
@@ -48,11 +48,11 @@
   }
 
   private final Provider<IdentifiedUser> user;
-  private final GerritPushCertificateChecker.Factory checkerFactory;
+  private final GerritPushCertificateCheckerFactory checkerFactory;
 
   @Inject
   public SignedPushPreReceiveHook(
-      Provider<IdentifiedUser> user, GerritPushCertificateChecker.Factory checkerFactory) {
+      Provider<IdentifiedUser> user, GerritPushCertificateCheckerFactory checkerFactory) {
     this.user = user;
     this.checkerFactory = checkerFactory;
   }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 652afea..6ae0334 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.GerritPushCertificateChecker;
+import com.google.gerrit.gpg.GerritPushCertificateCheckerFactory;
 import com.google.gerrit.gpg.PushCertificateChecker;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.server.PostGpgKeys;
@@ -44,14 +44,14 @@
   private final Provider<PostGpgKeys> postGpgKeys;
   private final Provider<GpgKeys> gpgKeys;
   private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
-  private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
+  private final GerritPushCertificateCheckerFactory pushCertCheckerFactory;
 
   @Inject
   GpgApiAdapterImpl(
       Provider<PostGpgKeys> postGpgKeys,
       Provider<GpgKeys> gpgKeys,
       GpgKeyApiImpl.Factory gpgKeyApiFactory,
-      GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
+      GerritPushCertificateCheckerFactory pushCertCheckerFactory) {
     this.postGpgKeys = postGpgKeys;
     this.gpgKeys = gpgKeys;
     this.gpgKeyApiFactory = gpgKeyApiFactory;
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index e0c921d..bcc8631 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -95,7 +95,7 @@
 
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
       cb.setCommitter(committer);
       cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
 
diff --git a/java/com/google/gerrit/gpg/server/GpgKey.java b/java/com/google/gerrit/gpg/server/GpgKey.java
index aa6b6f4..fbe97ad 100644
--- a/java/com/google/gerrit/gpg/server/GpgKey.java
+++ b/java/com/google/gerrit/gpg/server/GpgKey.java
@@ -21,8 +21,7 @@
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 
 public class GpgKey extends AccountResource {
-  public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND =
-      new TypeLiteral<RestView<GpgKey>>() {};
+  public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND = new TypeLiteral<>() {};
 
   private final PGPPublicKeyRing keyRing;
 
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index d46b344..d51ee6a 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
@@ -164,7 +165,7 @@
     }
   }
 
-  private Map<ExternalId, Fingerprint> readKeysToRemove(
+  private ImmutableMap<ExternalId, Fingerprint> readKeysToRemove(
       GpgKeysInput input, Collection<ExternalId> existingExtIds) {
     if (input.delete == null || input.delete.isEmpty()) {
       return ImmutableMap.of();
@@ -179,10 +180,11 @@
         // Skip removal.
       }
     }
-    return fingerprints;
+    return ImmutableMap.copyOf(fingerprints);
   }
 
-  private List<PGPPublicKeyRing> readKeysToAdd(GpgKeysInput input, Collection<Fingerprint> toRemove)
+  private ImmutableList<PGPPublicKeyRing> readKeysToAdd(
+      GpgKeysInput input, Collection<Fingerprint> toRemove)
       throws BadRequestException, IOException {
     if (input.add == null || input.add.isEmpty()) {
       return ImmutableList.of();
@@ -206,7 +208,7 @@
         throw new BadRequestException("Failed to parse GPG keys", e);
       }
     }
-    return keyRings;
+    return ImmutableList.copyOf(keyRings);
   }
 
   private void storeKeys(
@@ -249,7 +251,7 @@
       }
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(user.newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(user.newCommitterIdent(committer));
       cb.setCommitter(committer);
 
       RefUpdate.Result saveResult = store.save(cb);
@@ -298,6 +300,7 @@
     return externalIdKeyFactory.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
   }
 
+  @Nullable
   private Account getAccountByExternalId(ExternalId.Key extIdKey) {
     List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
 
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index ea7c609..0142031 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -37,10 +37,12 @@
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
+        "//lib/jsoup",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 92e16ce..9625039 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -19,6 +19,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.restapi.ParameterParser;
@@ -40,7 +41,9 @@
 
 public abstract class CacheBasedWebSession extends WebSession {
   @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
-  protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
+
+  @UsedAt(UsedAt.Project.PLUGIN_WEBSESSION_FLATFILE)
+  public static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
   private final HttpServletRequest request;
   private final HttpServletResponse response;
@@ -113,6 +116,7 @@
     }
   }
 
+  @Nullable
   private static String readCookie(HttpServletRequest request) {
     Cookie[] all = request.getCookies();
     if (all != null) {
@@ -216,6 +220,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String getSessionId() {
     return val != null ? val.getSessionId() : null;
diff --git a/java/com/google/gerrit/httpd/CanonicalWebUrl.java b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
index 437ddf3..3b04884 100644
--- a/java/com/google/gerrit/httpd/CanonicalWebUrl.java
+++ b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
@@ -37,6 +37,7 @@
     return url != null ? url : computeFromRequest(req);
   }
 
+  @SuppressWarnings("JdkObsolete")
   static String computeFromRequest(HttpServletRequest req) {
     StringBuffer url = req.getRequestURL();
     try {
diff --git a/java/com/google/gerrit/httpd/CookieBase64.java b/java/com/google/gerrit/httpd/CookieBase64.java
index 52cfde7..376ae1d 100644
--- a/java/com/google/gerrit/httpd/CookieBase64.java
+++ b/java/com/google/gerrit/httpd/CookieBase64.java
@@ -75,7 +75,7 @@
         out.append(enc[(inBuff >>> 18)]);
         out.append(enc[(inBuff >>> 12) & 0x3f]);
         out.append(enc[(inBuff >>> 6) & 0x3f]);
-        out.append(enc[(inBuff) & 0x3f]);
+        out.append(enc[inBuff & 0x3f]);
         break;
 
       case 2:
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index dcdbc13..e513a72 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.httpd;
 
+import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableListMultimap;
 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.Capable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -54,6 +58,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.text.MessageFormat;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.Collections;
@@ -70,10 +75,13 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
+import org.eclipse.jgit.errors.PackProtocolException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.http.server.GitServlet;
 import org.eclipse.jgit.http.server.GitSmartHttpTools;
+import org.eclipse.jgit.http.server.HttpServerText;
 import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.http.server.UploadPackErrorHandler;
 import org.eclipse.jgit.http.server.resolver.AsIsFileService;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -100,6 +108,23 @@
   private static final String ID_CACHE = "adv_bases";
 
   public static final String URL_REGEX;
+  public static final String GIT_COMMAND_STATUS_HEADER = "X-git-command-status";
+
+  private enum GIT_COMMAND_STATUS {
+    OK(0),
+    FAIL(-1);
+
+    private final int exitStatus;
+
+    GIT_COMMAND_STATUS(int exitStatus) {
+      this.exitStatus = exitStatus;
+    }
+
+    @Override
+    public String toString() {
+      return Integer.toString(exitStatus);
+    }
+  }
 
   static {
     StringBuilder url = new StringBuilder();
@@ -212,12 +237,14 @@
       Resolver resolver,
       UploadFactory upload,
       UploadFilter uploadFilter,
+      GerritUploadPackErrorHandler uploadPackErrorHandler,
       ReceivePackFactory<HttpServletRequest> receive,
       ReceiveFilter receiveFilter) {
     setRepositoryResolver(resolver);
     setAsIsFileService(AsIsFileService.DISABLED);
 
     setUploadPackFactory(upload);
+    setUploadPackErrorHandler(uploadPackErrorHandler);
     addUploadPackFilter(uploadFilter);
 
     setReceivePackFactory(receive);
@@ -406,14 +433,14 @@
         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;
+          if (!perm.test(ProjectPermission.RUN_UPLOAD_PACK)) {
+            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);
@@ -456,6 +483,36 @@
     public void destroy() {}
   }
 
+  static class GerritUploadPackErrorHandler implements UploadPackErrorHandler {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+    @Override
+    public void upload(HttpServletRequest req, HttpServletResponse rsp, UploadPackRunnable r)
+        throws IOException {
+      rsp.setHeader(GIT_COMMAND_STATUS_HEADER, GIT_COMMAND_STATUS.FAIL.toString());
+      try {
+        r.upload();
+        rsp.setHeader(GIT_COMMAND_STATUS_HEADER, GIT_COMMAND_STATUS.OK.toString());
+      } catch (ServiceMayNotContinueException e) {
+        if (!e.isOutput() && !rsp.isCommitted()) {
+          rsp.reset();
+          sendError(req, rsp, e.getStatusCode(), e.getMessage());
+        }
+      } catch (Throwable e) {
+        logger.atSevere().withCause(e).log(
+            "%s",
+            MessageFormat.format(
+                HttpServerText.get().internalErrorDuringUploadPack,
+                ServletUtils.getRepository(req)));
+        if (!rsp.isCommitted()) {
+          rsp.reset();
+          String msg = e instanceof PackProtocolException ? e.getMessage() : null;
+          sendError(req, rsp, UploadPackErrorHandler.statusCodeForThrowable(e), msg);
+        }
+      }
+    }
+  }
+
   static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
     private final AsyncReceiveCommits.Factory factory;
     private final Provider<CurrentUser> userProvider;
@@ -471,7 +528,7 @@
         throws ServiceNotAuthorizedException {
       final ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
 
-      if (!(userProvider.get().isIdentifiedUser())) {
+      if (!userProvider.get().isIdentifiedUser()) {
         // Anonymous users are not permitted to push.
         throw new ServiceNotAuthorizedException();
       }
@@ -535,18 +592,18 @@
       Capable canUpload;
       try {
         try {
-          permissionBackend
+          if (!permissionBackend
               .currentUser()
               .project(state.getNameKey())
-              .check(ProjectPermission.RUN_RECEIVE_PACK);
+              .test(ProjectPermission.RUN_RECEIVE_PACK)) {
+            GitSmartHttpTools.sendError(
+                httpRequest,
+                responseWrapper,
+                HttpServletResponse.SC_FORBIDDEN,
+                "receive-pack not permitted on this server");
+            return;
+          }
           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);
         }
@@ -578,7 +635,7 @@
         return;
       }
 
-      if (!(userProvider.get().isIdentifiedUser())) {
+      if (!userProvider.get().isIdentifiedUser()) {
         chain.doFilter(request, responseWrapper);
         return;
       }
@@ -610,6 +667,7 @@
     public void destroy() {}
   }
 
+  @Nullable
   private static String getSessionIdOrNull(Provider<WebSession> sessionProvider) {
     WebSession session = sessionProvider.get();
     if (session.isSignedIn()) {
diff --git a/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java b/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java
new file mode 100644
index 0000000..7c8094a
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java
@@ -0,0 +1,84 @@
+// 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;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.RemotePeer;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.Provider;
+import com.google.inject.servlet.ServletScopes;
+import com.google.inject.util.Providers;
+import com.google.inject.util.Types;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.net.SocketAddress;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import javax.servlet.http.HttpServletRequest;
+
+/** Propagator for Guice's built-in servlet scope. */
+public class GuiceRequestScopePropagator extends RequestScopePropagator {
+
+  private final String url;
+  private final SocketAddress peer;
+  private final Provider<HttpServletRequest> request;
+
+  @Inject
+  GuiceRequestScopePropagator(
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      @RemotePeer Provider<SocketAddress> remotePeerProvider,
+      ThreadLocalRequestContext local,
+      Provider<HttpServletRequest> request) {
+    super(ServletScopes.REQUEST, local);
+    this.url = urlProvider != null ? urlProvider.get() : null;
+    this.peer = remotePeerProvider.get();
+    this.request = request;
+  }
+
+  /** @see RequestScopePropagator#wrap(Callable) */
+  // ServletScopes#continueRequest is deprecated, but it's not obvious their
+  // recommended replacement is an appropriate drop-in solution; see
+  // https://gerrit-review.googlesource.com/83971
+  @SuppressWarnings("deprecation")
+  @Override
+  protected <T> Callable<T> wrapImpl(Callable<T> callable) {
+    Map<Key<?>, Object> seedMap = new HashMap<>();
+
+    // Request scopes appear to use specific keys in their map, instead of only
+    // providers. Add bindings for both the key to the instance directly and the
+    // provider to the instance to be safe.
+    seedMap.put(Key.get(typeOfProvider(String.class), CanonicalWebUrl.class), Providers.of(url));
+    seedMap.put(Key.get(String.class, CanonicalWebUrl.class), url);
+
+    seedMap.put(Key.get(typeOfProvider(SocketAddress.class), RemotePeer.class), Providers.of(peer));
+    seedMap.put(Key.get(SocketAddress.class, RemotePeer.class), peer);
+
+    Key<?> webSessionAttrKey = Key.get(WebSession.class);
+    Object webSessionAttrValue = request.get().getAttribute(webSessionAttrKey.toString());
+    seedMap.put(webSessionAttrKey, webSessionAttrValue);
+    seedMap.put(Key.get(typeOfProvider(WebSession.class)), Providers.of(webSessionAttrValue));
+
+    return ServletScopes.continueRequest(callable, seedMap);
+  }
+
+  private ParameterizedType typeOfProvider(Type type) {
+    return Types.newParameterizedType(Provider.class, type);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/HtmlDomUtil.java b/java/com/google/gerrit/httpd/HtmlDomUtil.java
index 57f2664..16e0938 100644
--- a/java/com/google/gerrit/httpd/HtmlDomUtil.java
+++ b/java/com/google/gerrit/httpd/HtmlDomUtil.java
@@ -16,7 +16,9 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -25,6 +27,8 @@
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import java.util.zip.GZIPOutputStream;
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
@@ -39,6 +43,7 @@
 import javax.xml.xpath.XPathExpression;
 import javax.xml.xpath.XPathExpressionException;
 import javax.xml.xpath.XPathFactory;
+import org.jsoup.parser.Parser;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 import org.w3c.dom.Node;
@@ -47,6 +52,8 @@
 
 /** Utility functions to deal with HTML using W3C DOM operations. */
 public class HtmlDomUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /** Standard character encoding we prefer (UTF-8). */
   public static final Charset ENC = UTF_8;
 
@@ -89,6 +96,7 @@
   }
 
   /** Find an element by its "id" attribute; null if no element is found. */
+  @Nullable
   public static Element find(Node parent, String name) {
     NodeList list = parent.getChildNodes();
     for (int i = 0; i < list.getLength(); i++) {
@@ -139,6 +147,7 @@
   }
 
   /** Parse an XHTML file from our CLASSPATH and return the instance. */
+  @Nullable
   public static Document parseFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
@@ -168,6 +177,7 @@
   }
 
   /** Read a Read a UTF-8 text file from our CLASSPATH and return it. */
+  @Nullable
   public static String readFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
@@ -180,6 +190,7 @@
   }
 
   /** Parse an XHTML file from the local drive and return the instance. */
+  @Nullable
   public static Document parseFile(Path path) throws IOException {
     try (InputStream in = Files.newInputStream(path)) {
       Document doc = newBuilder().parse(in);
@@ -193,6 +204,7 @@
   }
 
   /** Read a UTF-8 text file from the local drive. */
+  @Nullable
   public static String readFile(Path parentDir, String name) throws IOException {
     if (parentDir == null) {
       return null;
@@ -215,4 +227,27 @@
     factory.setCoalescing(true);
     return factory.newDocumentBuilder();
   }
+
+  /**
+   * Attaches nonce to all script elements in html.
+   *
+   * <p>The returned html is not guaranteed to have the same formatting as the input.
+   *
+   * @return Updated html or {#link Optional.empty()} if parsing failed.
+   */
+  public static Optional<String> attachNonce(String html, String nonce) {
+    Parser parser = Parser.htmlParser();
+    org.jsoup.nodes.Document document = parser.parseInput(html, "");
+    if (!parser.getErrors().isEmpty()) {
+      logger.atSevere().atMostEvery(5, TimeUnit.MINUTES).log(
+          "Html couldn't be parsed to attach nonce. Errors: %s", parser.getErrors());
+      return Optional.empty();
+    }
+    document.getElementsByTag("script").attr("nonce", nonce);
+    return Optional.of(
+        document
+            .outputSettings(
+                new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false).indentAmount(0))
+            .outerHtml());
+  }
 }
diff --git a/java/com/google/gerrit/httpd/HttpdModule.java b/java/com/google/gerrit/httpd/HttpdModule.java
index 1f1ec2f..41cef7a 100644
--- a/java/com/google/gerrit/httpd/HttpdModule.java
+++ b/java/com/google/gerrit/httpd/HttpdModule.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.extensions.annotations.Exports;
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index a421139..b0c7615 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -186,7 +186,7 @@
       if (passwordVerifier.checkPassword(who.externalIds(), username, password)) {
         return succeedAuthentication(who, null);
       }
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationFailedException e) {
@@ -200,7 +200,7 @@
       rsp.sendError(SC_SERVICE_UNAVAILABLE);
       return false;
     } catch (AccountException e) {
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -214,8 +214,8 @@
   private boolean failAuthentication(Response rsp, String username, HttpServletRequest req)
       throws IOException {
     logger.atWarning().log(
-        authenticationFailedMsg(username, req)
-            + ": password does not match the one stored in Gerrit");
+        "%s: password does not match the one stored in Gerrit",
+        authenticationFailedMsg(username, req));
     rsp.sendError(SC_UNAUTHORIZED);
     return false;
   }
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index fa53053..5a99cab 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -158,8 +159,8 @@
         accountCache.getByUsername(authInfo.username).filter(a -> a.account().isActive());
     if (!who.isPresent()) {
       logger.atWarning().log(
-          authenticationFailedMsg(authInfo.username, req)
-              + ": account inactive or not provisioned in Gerrit");
+          "%s: account inactive or not provisioned in Gerrit",
+          authenticationFailedMsg(authInfo.username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -180,7 +181,7 @@
       ws.setAccessPathOk(AccessPath.REST_API, true);
       return true;
     } catch (AccountException e) {
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(authInfo.username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(authInfo.username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -226,6 +227,7 @@
     }
   }
 
+  @Nullable
   private AuthInfo extractAuthInfo(String hdr, String encoding)
       throws UnsupportedEncodingException {
     byte[] decoded = BaseEncoding.base64().decode(hdr.substring(BASIC.length()));
@@ -241,6 +243,7 @@
         defaultAuthProvider);
   }
 
+  @Nullable
   private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
     String username =
         URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
@@ -272,6 +275,7 @@
     return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
   }
 
+  @Nullable
   private static Cookie findGitCookie(HttpServletRequest req) {
     Cookie[] cookies = req.getCookies();
     if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 84954dc..6f3e9c4 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import javax.servlet.http.HttpServletRequest;
 
 public class RemoteUserUtil {
@@ -62,6 +63,7 @@
    * @param auth header value which is used for extracting.
    * @return username if available or null.
    */
+  @Nullable
   public static String extractUsername(String auth) {
     auth = emptyToNull(auth);
 
diff --git a/java/com/google/gerrit/httpd/RequireSslFilter.java b/java/com/google/gerrit/httpd/RequireSslFilter.java
index a4a87e2..ca3c3d8 100644
--- a/java/com/google/gerrit/httpd/RequireSslFilter.java
+++ b/java/com/google/gerrit/httpd/RequireSslFilter.java
@@ -78,10 +78,7 @@
       //
       final String url;
       if (isLocalHost(req)) {
-        final StringBuffer b = req.getRequestURL();
-        b.replace(0, b.indexOf(":"), "https");
-        url = b.toString();
-
+        url = getLocalHostUrl(req);
       } else {
         url = urlProvider.get() + req.getServletPath();
       }
@@ -90,6 +87,13 @@
     }
   }
 
+  @SuppressWarnings("JdkObsolete")
+  private static String getLocalHostUrl(HttpServletRequest req) {
+    StringBuffer b = req.getRequestURL();
+    b.replace(0, b.indexOf(":"), "https");
+    return b.toString();
+  }
+
   private static boolean isSecure(HttpServletRequest req) {
     return "https".equals(req.getScheme()) || req.isSecure();
   }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 029efba..69adf82 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -92,11 +92,17 @@
     // which is bound in HttpPluginModule. We cannot bind it here again although
     // this means that plugins can't add REST views on PLUGIN_KIND.
     serveRegex("^/(?:a/)?access/(.*)$").with(AccessRestApiServlet.class);
+    serveRegex("^/(?:a/)?access$").with(AccessRestApiServlet.class);
     serveRegex("^/(?:a/)?accounts/(.*)$").with(AccountsRestApiServlet.class);
+    serveRegex("^/(?:a/)?accounts$").with(AccountsRestApiServlet.class);
     serveRegex("^/(?:a/)?changes/(.*)$").with(ChangesRestApiServlet.class);
+    serveRegex("^/(?:a/)?changes$").with(ChangesRestApiServlet.class);
     serveRegex("^/(?:a/)?config/(.*)$").with(ConfigRestApiServlet.class);
+    serveRegex("^/(?:a/)?config$").with(ConfigRestApiServlet.class);
     serveRegex("^/(?:a/)?groups/(.*)?$").with(GroupsRestApiServlet.class);
+    serveRegex("^/(?:a/)?groups$").with(GroupsRestApiServlet.class);
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
+    serveRegex("^/(?:a/)?projects$").with(ProjectsRestApiServlet.class);
 
     serveRegex("^/Documentation$").with(redirectDocumentation());
     serveRegex("^/Documentation/$").with(redirectDocumentation());
diff --git a/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java
index da485cc..79dde85 100644
--- a/java/com/google/gerrit/httpd/WebModule.java
+++ b/java/com/google/gerrit/httpd/WebModule.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
-import com.google.gerrit.server.util.GuiceRequestScopePropagator;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index 87bf3a6..1137b65 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,6 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -149,6 +150,7 @@
     return -1;
   }
 
+  @Nullable
   Val get(Key key) {
     Val val = self.getIfPresent(key.token);
     if (val != null && val.expiresAt <= nowMs()) {
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 2f760f0..bc8a01a 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -185,6 +186,7 @@
     return account.map(a -> new AuthResult(a.account().id(), null, false));
   }
 
+  @Nullable
   private AuthResult auth(Account.Id account) {
     if (account != null) {
       return new AuthResult(account, null, false);
@@ -192,6 +194,7 @@
     return null;
   }
 
+  @Nullable
   private AuthResult byUserName(String userName) {
     List<AccountState> accountStates = queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
     if (accountStates.isEmpty()) {
@@ -223,6 +226,7 @@
     }
   }
 
+  @Nullable
   private AuthResult create() throws IOException {
     try {
       return accountManager.authenticate(
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index acb3282..be833ea 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -21,6 +21,7 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.RemoteUserUtil;
@@ -143,6 +144,7 @@
         : remoteUser;
   }
 
+  @Nullable
   String getRemoteDisplayname(HttpServletRequest req) {
     if (displaynameHeader != null) {
       String raw = req.getHeader(displaynameHeader);
@@ -151,6 +153,7 @@
     return null;
   }
 
+  @Nullable
   String getRemoteEmail(HttpServletRequest req) {
     if (emailHeader != null) {
       return emptyToNull(req.getHeader(emailHeader));
@@ -158,6 +161,7 @@
     return null;
   }
 
+  @Nullable
   String getRemoteExternalIdToken(HttpServletRequest req) {
     if (externalIdHeader != null) {
       return emptyToNull(req.getHeader(externalIdHeader));
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
index 820c7a2..59a7379 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.server.account.AccountException;
@@ -36,8 +35,6 @@
 
 @Singleton
 class HttpsClientSslCertAuthFilter implements Filter {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final Pattern REGEX_USERID = Pattern.compile("CN=([^,]*)");
 
   private final DynamicItem<WebSession> webSession;
@@ -79,9 +76,7 @@
     try {
       arsp = accountManager.authenticate(areq);
     } catch (AccountException e) {
-      String err = "Unable to authenticate user \"" + userName + "\"";
-      logger.atSevere().withCause(e).log(err);
-      throw new ServletException(err, e);
+      throw new ServletException("Unable to authenticate user \"" + userName + "\"", e);
     }
     webSession.get().login(arsp, true);
     chain.doFilter(req, rsp);
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index a3f8fbda..297505a 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -190,7 +190,7 @@
     } else if (claimedId.isPresent() && !actualId.isPresent()) {
       // Claimed account already exists: link to it.
       //
-      logger.atInfo().log("OAuth2: linking claimed identity to %s", claimedId.get().toString());
+      logger.atInfo().log("OAuth2: linking claimed identity to %s", claimedId.get());
       try {
         accountManager.link(claimedId.get(), req);
       } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
index 2642a543..935762f 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
@@ -31,9 +31,9 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Map;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -175,12 +175,12 @@
   }
 
   private void pickSSOServiceProvider() throws ServletException {
-    SortedSet<String> plugins = oauthServiceProviders.plugins();
+    NavigableSet<String> plugins = oauthServiceProviders.plugins();
     if (plugins.isEmpty()) {
       throw new ServletException("OAuth service provider wasn't installed");
     }
     if (plugins.size() == 1) {
-      SortedMap<String, Provider<OAuthServiceProvider>> services =
+      NavigableMap<String, Provider<OAuthServiceProvider>> services =
           oauthServiceProviders.byPlugin(Iterables.getOnlyElement(plugins));
       if (services.size() == 1) {
         ssoProvider = Iterables.getOnlyElement(services.values()).get();
diff --git a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 0b6008c..e7057ad 100644
--- a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -334,6 +334,7 @@
     form.appendChild(div);
   }
 
+  @Nullable
   private OAuthServiceProvider lookupOAuthServiceProvider(String providerId) {
     if (providerId.startsWith("http://")) {
       providerId = providerId.substring("http://".length());
@@ -350,6 +351,7 @@
     return null;
   }
 
+  @Nullable
   private static String getLastId(HttpServletRequest req) {
     Cookie[] cookies = req.getCookies();
     if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
index 2e8585d..3d9c819 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
@@ -21,8 +21,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -79,9 +79,9 @@
   }
 
   private void pickSSOServiceProvider() {
-    SortedSet<String> plugins = oauthServiceProviders.plugins();
+    NavigableSet<String> plugins = oauthServiceProviders.plugins();
     if (plugins.size() == 1) {
-      SortedMap<String, Provider<OAuthServiceProvider>> services =
+      NavigableMap<String, Provider<OAuthServiceProvider>> services =
           oauthServiceProviders.byPlugin(Iterables.getOnlyElement(plugins));
       if (services.size() == 1) {
         ssoProvider = Iterables.getOnlyElement(services.values()).get();
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index cf3562f..0c71d68 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.auth.openid;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.auth.openid.OpenIdUrls;
 import com.google.gerrit.entities.Account;
@@ -178,7 +179,7 @@
         aReq.addExtension(pape);
       }
     } catch (MessageException | ConsumerException e) {
-      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s" + openidIdentifier);
+      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s", openidIdentifier);
       return new DiscoveryResult(DiscoveryResult.Status.ERROR);
     }
 
@@ -518,6 +519,7 @@
     rsp.sendRedirect(rdr.toString());
   }
 
+  @Nullable
   private State init(
       HttpServletRequest req,
       final String openidIdentifier,
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
index d499768..9ab51c5 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/BUILD
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -6,6 +6,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 3594c7c..2eee415 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -70,6 +71,7 @@
     return Response.ok(accessTokenInfo);
   }
 
+  @Nullable
   private static String getHostName(String canonicalWebUrl) {
     if (canonicalWebUrl == null) {
       logger.atSevere().log(
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 897d96f..4270150 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -43,12 +43,13 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.config.GitwebConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.DelegateRepository;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -73,7 +74,7 @@
 import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Enumeration;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -85,6 +86,7 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -101,13 +103,14 @@
   private final Set<String> deniedActions;
   private final Path gitwebCgi;
   private final URI gitwebUrl;
-  private final LocalDiskRepositoryManager repoManager;
+  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final Provider<AnonymousUser> anonymousUserProvider;
   private final Provider<CurrentUser> userProvider;
   private final EnvList _env;
 
+  @SuppressWarnings("CheckReturnValue")
   @Inject
   GitwebServlet(
       GitRepositoryManager repoManager,
@@ -119,12 +122,10 @@
       SshInfo sshInfo,
       Provider<AnonymousUser> anonymousUserProvider,
       GitwebConfig gitwebConfig,
-      GitwebCgiConfig gitwebCgiConfig)
+      GitwebCgiConfig gitwebCgiConfig,
+      AllProjectsName allProjects)
       throws IOException {
-    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
-      throw new ProvisionException("Gitweb can only be used with LocalDiskRepositoryManager");
-    }
-    this.repoManager = (LocalDiskRepositoryManager) repoManager;
+    this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.anonymousUserProvider = anonymousUserProvider;
@@ -132,8 +133,11 @@
     this.gitwebCgi = gitwebCgiConfig.getGitwebCgi();
     this.deniedActions = new HashSet<>();
 
+    // ensure that Gitweb works on supported repository type by checking All-Projects project
+    getProjectRoot(allProjects);
+
     final String url = gitwebConfig.getUrl();
-    if ((url != null) && (!url.equals("gitweb"))) {
+    if (url != null && !url.equals("gitweb")) {
       URI uri = null;
       try {
         uri = new URI(url);
@@ -537,7 +541,8 @@
     }
   }
 
-  private String[] makeEnv(HttpServletRequest req, ProjectState projectState) {
+  private String[] makeEnv(HttpServletRequest req, ProjectState projectState)
+      throws RepositoryNotFoundException, IOException {
     final EnvList env = new EnvList(_env);
     final int contentLength = Math.max(0, req.getContentLength());
 
@@ -568,9 +573,7 @@
     env.set("SERVER_PROTOCOL", req.getProtocol());
     env.set("SERVER_SOFTWARE", getServletContext().getServerInfo());
 
-    final Enumeration<String> hdrs = enumerateHeaderNames(req);
-    while (hdrs.hasMoreElements()) {
-      final String name = hdrs.nextElement();
+    for (String name : getHeaderNames(req)) {
       final String value = req.getHeader(name);
       env.set("HTTP_" + name.toUpperCase().replace('-', '_'), value);
     }
@@ -579,7 +582,7 @@
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
     env.set("GERRIT_PROJECT_NAME", nameKey.get());
 
-    env.set("GITWEB_PROJECTROOT", repoManager.getBasePath(nameKey).toAbsolutePath().toString());
+    env.set("GITWEB_PROJECTROOT", getProjectRoot(nameKey));
 
     if (projectState.statePermitsRead()
         && permissionBackend
@@ -636,6 +639,25 @@
     return env.getEnvArray();
   }
 
+  private String getProjectRoot(Project.NameKey nameKey)
+      throws RepositoryNotFoundException, IOException {
+    try (Repository repo = repoManager.openRepository(nameKey)) {
+      return getProjectRoot(repo);
+    }
+  }
+
+  private String getProjectRoot(Repository repo) {
+    if (repo instanceof DelegateRepository) {
+      return getProjectRoot(((DelegateRepository) repo).delegate());
+    }
+
+    if (repo instanceof FileRepository) {
+      return repo.getDirectory().getAbsolutePath();
+    }
+
+    throw new ProvisionException("Gitweb can only be used with FileRepository");
+  }
+
   private void copyContentToCGI(HttpServletRequest req, OutputStream dst) throws IOException {
     final int contentLength = req.getContentLength();
     final InputStream src = req.getInputStream();
@@ -677,7 +699,7 @@
                         .collect(Collectors.joining("\n"))
                         .trim();
                 if (!err.isEmpty()) {
-                  logger.atSevere().log(err);
+                  logger.atSevere().log("%s", err);
                 }
               } catch (IOException e) {
                 logger.atSevere().withCause(e).log("Unexpected error copying stderr from CGI");
@@ -687,10 +709,6 @@
         .start();
   }
 
-  private static Enumeration<String> enumerateHeaderNames(HttpServletRequest req) {
-    return req.getHeaderNames();
-  }
-
   private void readCgiHeaders(HttpServletResponse res, InputStream in) throws IOException {
     String line;
     while (!(line = readLine(in)).isEmpty()) {
@@ -731,6 +749,11 @@
     return buf.toString().trim();
   }
 
+  @SuppressWarnings("JdkObsolete")
+  private static Iterable<String> getHeaderNames(HttpServletRequest req) {
+    return Collections.list(req.getHeaderNames());
+  }
+
   /** private utility class that manages the Environment passed to exec. */
   private static class EnvList {
     private Map<String, String> envMap;
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index adfbdcc..990b5d7 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -6,7 +6,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/auth",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 13df520..df2c5cb 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -19,7 +19,6 @@
 import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.AuthModule;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
@@ -53,8 +52,10 @@
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
 import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
 import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -82,6 +83,7 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
@@ -106,6 +108,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.ExternalIdCommandsModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
 import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand.LfsPluginAuthCommandModule;
@@ -208,7 +211,7 @@
           buf.append("\nResolve above errors before continuing.");
           buf.append("\nComplete stack trace follows:");
         }
-        logger.atSevere().withCause(first.getCause()).log(buf.toString());
+        logger.atSevere().withCause(first.getCause()).log("%s", buf);
         throw new CreationException(Collections.singleton(first));
       }
 
@@ -290,7 +293,7 @@
     modules.add(new AuthConfigModule());
     return cfgInjector.createChildInjector(
         ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE_TYPE)));
   }
 
   private Injector createSysInjector() {
@@ -306,6 +309,7 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
     modules.add(new PluginApiModule());
     modules.add(new SearchingChangeCacheImplModule());
     modules.add(new InternalAccountDirectoryModule());
@@ -343,7 +347,7 @@
         });
     modules.add(new DefaultUrlFormatterModule());
 
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     modules.add(SshKeyCacheImpl.module());
     modules.add(
         new AbstractModule() {
@@ -357,16 +361,15 @@
     modules.add(new ChangeCleanupRunnerModule());
     modules.add(new AccountDeactivatorModule());
     modules.add(new DefaultProjectNameLockManagerModule());
+    modules.add(new ExternalIdCaseSensitivityMigrator.ExternalIdCaseSensitivityMigratorModule());
     return dbInjector.createChildInjector(
         ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE_TYPE)));
   }
 
   private Module createIndexModule() {
     if (indexType.isLucene()) {
-      return LuceneIndexModule.latestVersion(false);
-    } else if (indexType.isElasticsearch()) {
-      return ElasticIndexModule.latestVersion(false);
+      return LuceneIndexModule.latestVersion(false, AutoFlush.ENABLED);
     } else if (indexType.isFake()) {
       // Use Reflection so that we can omit the fake index binary in production code. Test code does
       // compile the component in.
@@ -400,6 +403,7 @@
             sysInjector.getInstance(LfsPluginAuthCommandModule.class)));
     modules.add(new IndexCommandsModule(sysInjector));
     modules.add(new SequenceCommandsModule());
+    modules.add(new ExternalIdCommandsModule());
     return sysInjector.createChildInjector(modules);
   }
 
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index ef37fc5..9b8f4c6 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -35,6 +35,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
 import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.SmallResource;
@@ -68,7 +69,6 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -86,7 +86,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -175,6 +175,7 @@
     plugins.put(name, holder);
   }
 
+  @Nullable
   private GuiceFilter load(Plugin plugin) {
     if (plugin.getHttpInjector() != null) {
       final String name = plugin.getName();
@@ -328,6 +329,7 @@
     }
   }
 
+  @Nullable
   private static Pattern makeAllowOrigin(Config cfg) {
     String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
     if (allow.length > 0) {
@@ -447,8 +449,7 @@
           return false;
         };
 
-    List<PluginEntry> entries =
-        Collections.list(scanner.entries()).stream().filter(filter).collect(toList());
+    List<PluginEntry> entries = scanner.entries().filter(filter).collect(toList());
     for (PluginEntry entry : entries) {
       String name = entry.getName().substring(prefix.length());
       if (name.startsWith("cmd-")) {
@@ -491,7 +492,7 @@
     }
 
     if (toc != null) {
-      appendPageAsSection(scanner, toc, "Documentaion", md);
+      appendPageAsSection(scanner, toc, "Documentation", md);
     } else {
       appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
       appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
@@ -520,7 +521,7 @@
     macros.put("URL", url);
 
     Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(md);
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     while (m.find()) {
       String key = m.group(2);
       String val = macros.get(key);
@@ -722,6 +723,7 @@
       this.docPrefix = getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
     }
 
+    @Nullable
     private static String getPrefix(Plugin plugin, String attr, String def) {
       Path path = plugin.getSrcFile();
       PluginContentScanner scanner = plugin.getContentScanner();
diff --git a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
index fc0ec39..ed29629 100644
--- a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -19,6 +19,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.plugins.Plugin;
@@ -119,6 +120,7 @@
     filter.set(guiceFilter);
   }
 
+  @Nullable
   private GuiceFilter load(Plugin plugin) {
     if (plugin.getHttpInjector() != null) {
       final String name = plugin.getName();
diff --git a/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
index 40083e4..5e875d7 100644
--- a/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
+++ b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
@@ -61,11 +61,11 @@
       try {
         handler = API.class.getDeclaredMethod(method.getName(), method.getParameterTypes());
       } catch (NoSuchMethodException e) {
-        String msg =
-            String.format(
-                "%s does not implement %s", PluginServletContext.class, method.toGenericString());
-        logger.atSevere().withCause(e).log(msg);
-        throw new NoSuchMethodError(msg);
+        throw new NoSuchMethodError(
+                String.format(
+                    "%s does not implement %s",
+                    PluginServletContext.class, method.toGenericString()))
+            .initCause(e);
       }
       return handler.invoke(this, args);
     }
@@ -199,6 +199,11 @@
       String v = Version.getVersion();
       return "Gerrit Code Review/" + (v != null ? v : "dev");
     }
+
+    @Override
+    public String getVirtualServerName() {
+      return null;
+    }
   }
 
   interface API {
@@ -255,5 +260,7 @@
     int getMinorVersion();
 
     String getServerInfo();
+
+    String getVirtualServerName();
   }
 }
diff --git a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
index c13286e..3f59084 100644
--- a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import java.nio.file.Path;
 
-class DirectoryDocServlet extends ResourceServlet {
+class DirectoryDocServlet extends DocServlet {
   private static final long serialVersionUID = 1L;
 
   private final Path doc;
 
-  DirectoryDocServlet(Cache<Path, Resource> cache, Path unpackedWar) {
-    super(cache, true);
+  DirectoryDocServlet(
+      Cache<Path, Resource> cache, Path unpackedWar, ExperimentFeatures experimentFeatures) {
+    super(cache, true, experimentFeatures);
     this.doc = unpackedWar.resolve("Documentation");
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/DocServlet.java b/java/com/google/gerrit/httpd/raw/DocServlet.java
new file mode 100644
index 0000000..d5027ba
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/DocServlet.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+abstract class DocServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final ExperimentFeatures experimentFeatures;
+
+  DocServlet(Cache<Path, Resource> cache, boolean refresh, ExperimentFeatures experimentFeatures) {
+    super(cache, refresh);
+    this.experimentFeatures = experimentFeatures;
+  }
+
+  @Override
+  protected boolean shouldProcessResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Path p) {
+    String nonce = (String) req.getAttribute("nonce");
+    if (!experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+        || nonce == null) {
+      return false;
+    }
+    return ResourceServlet.contentType(p.toString()).equals("text/html");
+  }
+
+  @Override
+  protected Resource processResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+    // ResourceServlet doesn't set character encoding for a resource. Gerrit will
+    // default to setting charset to utf-8, if none provided. So we guess UTF_8 here.
+    Optional<String> updatedHtml =
+        HtmlDomUtil.attachNonce(
+            new String(resource.raw, StandardCharsets.UTF_8), (String) req.getAttribute("nonce"));
+    if (updatedHtml.isEmpty()) {
+      return resource;
+    }
+    return new Resource(
+        resource.lastModified,
+        resource.contentType,
+        updatedHtml.get().getBytes(StandardCharsets.UTF_8));
+  }
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 445a73a..72bfe40 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -39,6 +39,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
@@ -68,8 +69,10 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
-    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
-
+    Set<String> enabledExperiments = new HashSet<>();
+    enabledExperiments.addAll(experimentFeatures.getEnabledExperimentFeatures());
+    // Add all experiments enabled through url
+    enabledExperiments.addAll(IndexHtmlUtil.experimentData(urlParameterMap));
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
@@ -90,17 +93,13 @@
     IndexPreloadingUtil.RequestedPage page = IndexPreloadingUtil.parseRequestedPage(requestedPath);
     switch (page) {
       case CHANGE:
+      case DIFF:
         data.put(
             "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
-        break;
-      case DIFF:
-        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
-        data.put(
-            "changeRequestsPath",
-            IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+        data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
         break;
       case DASHBOARD:
         // Dashboard is preloaded queries are added later when we check user is authenticated.
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 3bdcb1a..5cf63d9 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -59,16 +59,15 @@
   public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
   public static final String YOUR_TURN = "attention:${user} limit:25";
   public static final String DASHBOARD_ASSIGNED_QUERY =
-      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored limit:25";
+      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open limit:25";
   public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
       "is:open owner:${user} is:wip limit:25";
-  public static final String DASHBOARD_OUTGOING_QUERY =
-      "is:open owner:${user} -is:wip -is:ignored limit:25";
+  public static final String DASHBOARD_OUTGOING_QUERY = "is:open owner:${user} -is:wip limit:25";
   public static final String DASHBOARD_INCOMING_QUERY =
-      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user}) limit:25";
-  public static final String CC_QUERY = "is:open -is:ignored -is:wip cc:${user} limit:10";
+      "is:open -owner:${user} -is:wip (reviewer:${user} OR assignee:${user}) limit:25";
+  public static final String CC_QUERY = "is:open -is:wip cc:${user} limit:10";
   public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
-      "is:closed -is:ignored (-is:wip OR owner:self) "
+      "is:closed (-is:wip OR owner:self) "
           + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
           + "OR cc:${user}) -age:4w limit:10";
   public static final String NEW_USER = "owner:${user} limit:1";
@@ -89,7 +88,10 @@
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
   public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
-      ImmutableSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS);
+      ImmutableSet.of(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
   public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
       ImmutableSet.of(
@@ -101,14 +103,10 @@
           ListChangesOption.MESSAGES,
           ListChangesOption.SUBMITTABLE,
           ListChangesOption.WEB_LINKS,
-          ListChangesOption.SKIP_DIFFSTAT);
+          ListChangesOption.SKIP_DIFFSTAT,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
-  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
-      ImmutableSet.of(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS,
-          ListChangesOption.SKIP_DIFFSTAT);
-
+  @Nullable
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
       return null;
@@ -170,6 +168,30 @@
     return Optional.empty();
   }
 
+  public static Optional<Integer> computeChangeNum(String requestedURL, RequestedPage page) {
+    Matcher matcher;
+    switch (page) {
+      case CHANGE:
+        matcher = CHANGE_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DIFF:
+        matcher = DIFF_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DASHBOARD:
+      case PAGE_WITHOUT_PRELOADING:
+      default:
+        return Optional.empty();
+    }
+
+    if (matcher.matches()) {
+      Integer changeNum = Ints.tryParse(matcher.group("changeNum"));
+      if (changeNum != null) {
+        return Optional.of(changeNum);
+      }
+    }
+    return Optional.empty();
+  }
+
   public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
     List<String> queryList = new ArrayList<>();
     queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index 3f2c202..fcb821e 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -38,6 +38,8 @@
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
+  private static final String POLY_GERRIT_INDEX_HTML_SOY =
+      "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
 
   @Nullable private final String canonicalUrl;
   @Nullable private final String cdnPath;
@@ -60,7 +62,7 @@
     this.experimentFeatures = experimentFeatures;
     this.soySauce =
         SoyFileSet.builder()
-            .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
+            .add(Resources.getResource(POLY_GERRIT_INDEX_HTML_SOY), POLY_GERRIT_INDEX_HTML_SOY)
             .build()
             .compileTemplates();
     this.urlOrdainer =
@@ -74,7 +76,6 @@
     SoySauce.Renderer renderer;
     try {
       Map<String, String[]> parameterMap = req.getParameterMap();
-      String requestUrl = req.getRequestURL() == null ? null : req.getRequestURL().toString();
       // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
@@ -85,7 +86,7 @@
               faviconPath,
               parameterMap,
               urlOrdainer,
-              requestUrl);
+              getRequestUrl(req));
       renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData);
     } catch (URISyntaxException | RestApiException e) {
       throw new IOException(e);
@@ -98,4 +99,13 @@
       w.write(renderer.renderHtml().get().toString().getBytes(UTF_8));
     }
   }
+
+  @SuppressWarnings("JdkObsolete")
+  @Nullable
+  private static String getRequestUrl(HttpServletRequest req) {
+    if (req.getRequestURL() == null) {
+      return null;
+    }
+    return req.getRequestURL().toString();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 8be4abc..871ec78 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -126,6 +126,34 @@
    */
   protected abstract Path getResourcePath(String pathInfo) throws IOException;
 
+  /**
+   * Indicates that resource requires some processing before being served.
+   *
+   * <p>If true, the caching headers in response are set to not cache. Additionally, streaming
+   * option is disabled.
+   *
+   * @param req the HTTP servlet request
+   * @param rsp the HTTP servlet response
+   * @param p URL path
+   * @return true if the {@link #processResourceBeforeServe(HttpServletRequest, HttpServletResponse,
+   *     Resource)} should be called.
+   */
+  protected boolean shouldProcessResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Path p) {
+    return false;
+  }
+
+  /**
+   * Edits the resource before adding it to the response.
+   *
+   * @param req the HTTP servlet request
+   * @param rsp the HTTP servlet response
+   */
+  protected Resource processResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+    return resource;
+  }
+
   protected FileTime getLastModifiedTime(Path p) throws IOException {
     return Files.getLastModifiedTime(p);
   }
@@ -148,10 +176,11 @@
       return;
     }
 
+    boolean requiresPostProcess = shouldProcessResourceBeforeServe(req, rsp, p);
     Resource r = cache.getIfPresent(p);
     try {
       if (r == null) {
-        if (maybeStream(p, req, rsp)) {
+        if (!requiresPostProcess && maybeStream(p, req, rsp)) {
           return; // Bypass cache for large resource.
         }
         r = cache.get(p, newLoader(p));
@@ -176,11 +205,16 @@
       CacheHeaders.setNotCacheable(rsp);
       rsp.setStatus(SC_NOT_FOUND);
       return;
-    } else if (cacheOnClient && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
+    } else if (!requiresPostProcess
+        && cacheOnClient
+        && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
       rsp.setStatus(SC_NOT_MODIFIED);
       return;
     }
 
+    if (requiresPostProcess) {
+      r = processResourceBeforeServe(req, rsp, r);
+    }
     byte[] tosend = r.raw;
     if (!r.contentType.equals(JS) && RequestUtil.acceptsGzipEncoding(req)) {
       byte[] gz = HtmlDomUtil.compress(tosend);
@@ -190,7 +224,7 @@
       }
     }
 
-    if (cacheOnClient) {
+    if (!requiresPostProcess && cacheOnClient) {
       rsp.setHeader(ETAG, r.etag);
     } else {
       CacheHeaders.setNotCacheable(rsp);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index aa32169..961bf9b 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -93,10 +93,12 @@
           "/elements/*",
           "/fonts/*",
           "/scripts/*",
-          "/styles/*");
+          "/styles/*",
+          "/workers/*");
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
+  private static final String SERVICE_WORKER_SERVLET = "ServiceWorkerServlet";
   private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
@@ -141,12 +143,13 @@
   @Provides
   @Singleton
   @Named(DOC_SERVLET)
-  HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+  HttpServlet getDocServlet(
+      @Named(CACHE) Cache<Path, Resource> cache, ExperimentFeatures experimentFeatures) {
     Paths p = getPaths();
     if (p.warFs != null) {
-      return new WarDocServlet(cache, p.warFs);
+      return new WarDocServlet(cache, p.warFs, experimentFeatures);
     } else if (p.unpackedWar != null && !p.isDev()) {
-      return new DirectoryDocServlet(cache, p.unpackedWar);
+      return new DirectoryDocServlet(cache, p.unpackedWar, experimentFeatures);
     } else {
       return new HttpServlet() {
         private static final long serialVersionUID = 1L;
@@ -165,6 +168,7 @@
     public void configureServlets() {
       serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
       serve("/favicon.ico").with(named(FAVICON_SERVLET));
+      serve("/service-worker.js").with(named(SERVICE_WORKER_SERVLET));
     }
 
     @Provides
@@ -199,6 +203,19 @@
       return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
     }
 
+    @Provides
+    @Singleton
+    @Named(SERVICE_WORKER_SERVLET)
+    HttpServlet getServiceWorkerServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/polygerrit_ui/workers/service-worker.js"), false);
+      }
+      return new SingleFileServlet(
+          cache, webappSourcePath("polygerrit_ui/workers/service-worker.js"), true);
+    }
+
     private Path webappSourcePath(String name) {
       Paths p = getPaths();
       if (p.unpackedWar != null) {
@@ -288,6 +305,7 @@
       sourceRoot = getSourceRootOrNull();
     }
 
+    @Nullable
     private static Path getSourceRootOrNull() {
       try {
         return GerritLauncher.resolveInSourceRoot(".");
@@ -296,6 +314,7 @@
       }
     }
 
+    @Nullable
     private FileSystem getDistributionArchive(File war) throws IOException {
       if (war == null) {
         return null;
@@ -303,6 +322,7 @@
       return GerritLauncher.getZipFileSystem(war.toPath());
     }
 
+    @Nullable
     private File getLauncherLoadedFrom() {
       File war;
       try {
@@ -424,6 +444,7 @@
       super(req);
     }
 
+    @Nullable
     @Override
     public String getPathInfo() {
       String uri = getRequestURI();
diff --git a/java/com/google/gerrit/httpd/raw/WarDocServlet.java b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
index 27520e3..718d46d 100644
--- a/java/com/google/gerrit/httpd/raw/WarDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
@@ -15,20 +15,22 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 import java.nio.file.attribute.FileTime;
 
-class WarDocServlet extends ResourceServlet {
+class WarDocServlet extends DocServlet {
   private static final long serialVersionUID = 1L;
 
   private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
 
   private final FileSystem warFs;
 
-  WarDocServlet(Cache<Path, Resource> cache, FileSystem warFs) {
-    super(cache, false);
+  WarDocServlet(
+      Cache<Path, Resource> cache, FileSystem warFs, ExperimentFeatures experimentFeatures) {
+    super(cache, false, experimentFeatures);
     this.warFs = warFs;
   }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 369ea29..44e7854 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -30,7 +30,6 @@
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -120,9 +119,7 @@
 import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.change.ChangeFinder;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
@@ -270,7 +267,6 @@
     final PluginSetContext<ExceptionHook> exceptionHooks;
     final Injector injector;
     final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
-    final ExperimentFeatures experimentFeatures;
     final DeadlineChecker.Factory deadlineCheckerFactory;
     final CancellationMetrics cancellationMetrics;
 
@@ -291,7 +287,6 @@
         PluginSetContext<ExceptionHook> exceptionHooks,
         Injector injector,
         DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
-        ExperimentFeatures experimentFeatures,
         DeadlineChecker.Factory deadlineCheckerFactory,
         CancellationMetrics cancellationMetrics) {
       this.currentUser = currentUser;
@@ -310,11 +305,11 @@
       allowOrigin = makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
-      this.experimentFeatures = experimentFeatures;
       this.deadlineCheckerFactory = deadlineCheckerFactory;
       this.cancellationMetrics = cancellationMetrics;
     }
 
+    @Nullable
     private static Pattern makeAllowOrigin(Config cfg) {
       String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
       if (allow.length > 0) {
@@ -363,7 +358,7 @@
 
       try (PerThreadCache ignored = PerThreadCache.create()) {
         List<IdString> path = splitPath(req);
-        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri, path);
+        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         // It's important that the PerformanceLogContext is closed before the response is sent to
@@ -825,7 +820,7 @@
       if (isRead(request)) {
         logger.atWarning().log(
             "request %s performed a ref update %s although the request is a READ request",
-            request.getRequestURL().toString(), refUpdateFormat);
+            request.getRequestURL(), refUpdateFormat);
       }
       response.addHeader(X_GERRIT_UPDATED_REF, refUpdateFormat);
     }
@@ -854,17 +849,13 @@
     }
   }
 
+  @Nullable
   private String getEtagWithRetry(
       HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "RestApiServlet#getEtagWithRetry:resource",
             Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
-      if (rsrc instanceof RevisionResource
-          && globals.experimentFeatures.isFeatureEnabled(
-              GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG)) {
-        return null;
-      }
       return invokeRestEndpointWithRetry(
           req,
           traceContext,
@@ -1277,6 +1268,7 @@
     return ((ParameterizedType) supertype).getActualTypeArguments()[2];
   }
 
+  @Nullable
   private Object parseRequest(HttpServletRequest req, Type type)
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
@@ -1394,22 +1386,21 @@
     throw new BadRequestException("Expected JSON object");
   }
 
-  @SuppressWarnings("unchecked")
   private static Object createInstance(Type type)
       throws NoSuchMethodException, InstantiationException, IllegalAccessException,
           InvocationTargetException {
     if (type instanceof Class) {
-      Class<Object> clazz = (Class<Object>) type;
-      Constructor<Object> c = clazz.getDeclaredConstructor();
+      Class<?> clazz = (Class<?>) type;
+      Constructor<?> c = clazz.getDeclaredConstructor();
       c.setAccessible(true);
       return c.newInstance();
     }
     if (type instanceof ParameterizedType) {
       Type rawType = ((ParameterizedType) type).getRawType();
-      if (rawType instanceof Class && List.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && List.class.isAssignableFrom((Class<?>) rawType)) {
         return new ArrayList<>();
       }
-      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<?>) rawType)) {
         return new HashMap<>();
       }
     }
@@ -1714,7 +1705,7 @@
   private static List<IdString> splitPath(HttpServletRequest req) {
     String path = RequestUtil.getEncodedPathInfo(req);
     if (Strings.isNullOrEmpty(path)) {
-      return Collections.emptyList();
+      return new ArrayList<>();
     }
     List<IdString> out = new ArrayList<>();
     for (String p : Splitter.on('/').split(path)) {
diff --git a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
index 655f4ca..2065a31 100644
--- a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
+++ b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -125,6 +126,7 @@
       return cssFile.isStale() || headerFile.isStale() || footerFile.isStale();
     }
 
+    @Nullable
     private static Element readXml(FileInfo src) throws IOException {
       Document d = HtmlDomUtil.parseFile(src.path);
       return d != null ? d.getDocumentElement() : null;
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 95b3581..ba1c8bd 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -28,9 +28,11 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:protobuf",
         "//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 76aa7cc..1c2074b 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -21,6 +21,9 @@
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -28,11 +31,18 @@
 /**
  * Definition of a field stored in the secondary index.
  *
+ * <p>{@link FieldDef}-s must not be changed once introduced to the codebase. Instead, a new
+ * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}).
+ *
+ * <p>Note that {@link FieldDef} does not override {@link Object#equals(Object)}. It relies on
+ * instances being singletons so that the default (i.e. reference) comparison works.
+ *
  * @param <I> input type from which documents are created and search results are returned.
  * @param <T> type that should be extracted from the input object when converting to an index
  *     document.
  */
-public final class FieldDef<I, T> {
+public final class FieldDef<I, T> implements SchemaField<I, T> {
   public static FieldDef.Builder<String> exact(String name) {
     return new FieldDef.Builder<>(FieldType.EXACT, name);
   }
@@ -61,17 +71,6 @@
     return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
   }
 
-  @FunctionalInterface
-  public interface Getter<I, T> {
-    @Nullable
-    T get(I input) throws IOException;
-  }
-
-  @FunctionalInterface
-  public interface Setter<I, T> {
-    void set(I object, T value);
-  }
-
   public static class Builder<T> {
     private final FieldType<T> type;
     private final String name;
@@ -139,16 +138,19 @@
   }
 
   /** Returns name of the field. */
+  @Override
   public String getName() {
     return name;
   }
 
   /** Returns type of the field; for repeatable fields, the inner type, not the iterable type. */
+  @Override
   public FieldType<?> getType() {
     return type;
   }
 
   /** Returns whether the field should be stored in the index. */
+  @Override
   public boolean isStored() {
     return stored;
   }
@@ -159,6 +161,7 @@
    * @param input input object.
    * @return the field value(s) to index.
    */
+  @Override
   @Nullable
   public T get(I input) {
     try {
@@ -177,6 +180,7 @@
    * @return {@code true} if the field was set, {@code false} otherwise
    */
   @SuppressWarnings("unchecked")
+  @Override
   public boolean setIfPossible(I object, StoredValue doc) {
     if (!setter.isPresent()) {
       return false;
@@ -204,6 +208,7 @@
   }
 
   /** Returns whether the field is repeatable. */
+  @Override
   public boolean isRepeatable() {
     return repeatable;
   }
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index ead302d..870d827 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -40,6 +40,16 @@
   void close();
 
   /**
+   * Insert a document into the index.
+   *
+   * <p>Results may not be immediately visible to searchers, but should be visible within a
+   * reasonable amount of time.
+   *
+   * @param obj document object
+   */
+  void insert(V obj);
+
+  /**
    * Update a document in the index.
    *
    * <p>Semantically equivalent to deleting the document and reinserting it with new field values. A
@@ -50,6 +60,9 @@
    */
   void replace(V obj);
 
+  /** Delete a document from the index by value */
+  void deleteByValue(V value);
+
   /**
    * Delete a document from the index by key.
    *
@@ -143,4 +156,14 @@
   default boolean isEnabled() {
     return true;
   }
+
+  /**
+   * Rewriter that should be invoked on queries to this index.
+   *
+   * <p>The default implementation does not do anything. Should be overridden by implementation, if
+   * needed.
+   */
+  default IndexRewriter<V> getIndexRewriter() {
+    return (in, opts) -> in;
+  }
 }
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
index 8676fb2..c21f32e 100644
--- a/java/com/google/gerrit/index/IndexConfig.java
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -30,6 +30,7 @@
 @AutoValue
 public abstract class IndexConfig {
   private static final int DEFAULT_MAX_TERMS = 1024;
+  private static final int DEFAULT_PAGE_SIZE_MULTIPLIER = 1;
 
   public static IndexConfig createDefault() {
     return builder().build();
@@ -40,7 +41,10 @@
     setIfPresent(cfg, "maxLimit", b::maxLimit);
     setIfPresent(cfg, "maxPages", b::maxPages);
     setIfPresent(cfg, "maxTerms", b::maxTerms);
+    setIfPresent(cfg, "pageSizeMultiplier", b::pageSizeMultiplier);
+    setIfPresent(cfg, "maxPageSize", b::maxPageSize);
     setTypeOrDefault(cfg, b::type);
+    setPaginationTypeOrDefault(cfg, b::paginationType);
     return b;
   }
 
@@ -56,13 +60,21 @@
     setter.accept(new IndexType(type).toString());
   }
 
+  private static void setPaginationTypeOrDefault(Config cfg, Consumer<PaginationType> setter) {
+    setter.accept(
+        cfg != null ? cfg.getEnum("index", null, "paginationType", PaginationType.OFFSET) : null);
+  }
+
   public static Builder builder() {
     return new AutoValue_IndexConfig.Builder()
         .maxLimit(Integer.MAX_VALUE)
         .maxPages(Integer.MAX_VALUE)
         .maxTerms(DEFAULT_MAX_TERMS)
+        .pageSizeMultiplier(DEFAULT_PAGE_SIZE_MULTIPLIER)
+        .maxPageSize(Integer.MAX_VALUE)
         .type(IndexType.getDefault())
-        .separateChangeSubIndexes(false);
+        .separateChangeSubIndexes(false)
+        .paginationType(PaginationType.OFFSET);
   }
 
   @AutoValue.Builder
@@ -85,6 +97,12 @@
 
     public abstract Builder separateChangeSubIndexes(boolean separate);
 
+    public abstract Builder paginationType(PaginationType type);
+
+    public abstract Builder pageSizeMultiplier(int pageSizeMultiplier);
+
+    public abstract Builder maxPageSize(int maxPageSize);
+
     abstract IndexConfig autoBuild();
 
     public IndexConfig build() {
@@ -92,6 +110,8 @@
       checkLimit(cfg.maxLimit(), "maxLimit");
       checkLimit(cfg.maxPages(), "maxPages");
       checkLimit(cfg.maxTerms(), "maxTerms");
+      checkLimit(cfg.pageSizeMultiplier(), "pageSizeMultiplier");
+      checkLimit(cfg.maxPageSize(), "maxPageSize");
       return cfg;
     }
   }
@@ -124,4 +144,21 @@
    * Returns whether different subsets of changes may be stored in different physical sub-indexes.
    */
   public abstract boolean separateChangeSubIndexes();
+
+  /**
+   * Returns pagination type to use when index queries are repeated to obtain the next set of
+   * results.
+   */
+  public abstract PaginationType paginationType();
+
+  /**
+   * Returns multiplier to be used to determine the limit when queries are repeated to obtain the
+   * next set of results.
+   */
+  public abstract int pageSizeMultiplier();
+
+  /**
+   * Returns maximum allowed limit when repeating index queries to obtain the next set of results.
+   */
+  public abstract int maxPageSize();
 }
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index 0c3a76a..75f8351 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -24,8 +24,7 @@
 /**
  * Index types supported by the secondary index.
  *
- * <p>The explicitly known index types are Lucene (the default), Elasticsearch and a fake index used
- * in tests.
+ * <p>The explicitly known index types are Lucene (the default) and a fake index used in tests.
  *
  * <p>The third supported index type is any other type String value, deemed as custom. This is for
  * configuring index types that are internal or not to be disclosed. Supporting custom index types
@@ -36,7 +35,6 @@
   private static final String ENV_VAR = "GERRIT_INDEX_TYPE";
 
   private static final String LUCENE = "lucene";
-  private static final String ELASTICSEARCH = "elasticsearch";
   private static final String FAKE = "fake";
 
   private final String type;
@@ -77,17 +75,13 @@
   }
 
   public static ImmutableSet<String> getKnownTypes() {
-    return ImmutableSet.of(LUCENE, ELASTICSEARCH, FAKE);
+    return ImmutableSet.of(LUCENE, FAKE);
   }
 
   public boolean isLucene() {
     return type.equals(LUCENE);
   }
 
-  public boolean isElasticsearch() {
-    return type.equals(ELASTICSEARCH);
-  }
-
   public boolean isFake() {
     return type.equals(FAKE);
   }
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
new file mode 100644
index 0000000..99004bb
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -0,0 +1,531 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.converter.ProtoConverter;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Protos;
+import com.google.protobuf.MessageLite;
+import java.io.IOException;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.StreamSupport;
+
+/**
+ * Definition of a field stored in the secondary index.
+ *
+ * <p>Each IndexedField, stored in index, may have multiple {@link SearchSpec} which defines how it
+ * can be searched and how the index tokens are generated.
+ *
+ * <p>Index implementations may choose to store IndexedField and {@link SearchSpec} (search tokens)
+ * separately, however {@link com.google.gerrit.index.query.IndexedQuery} always issues the queries
+ * to {@link SearchSpec}.
+ *
+ * <p>This allows index implementations to store IndexedField once, while enabling multiple
+ * tokenization strategies on the same IndexedField with {@link SearchSpec}
+ *
+ * @param <I> input type from which documents are created and search results are returned.
+ * @param <T> type that should be extracted from the input object when converting to an index
+ *     document.
+ */
+// TODO(mariasavtchouk): revisit the class name after migration is done.
+@SuppressWarnings("serial")
+@AutoValue
+public abstract class IndexedField<I, T> {
+
+  public static final TypeToken<Integer> INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Integer>> ITERABLE_INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Long> LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Long>> ITERABLE_LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<String> STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<String>> ITERABLE_STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<byte[]> BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<byte[]>> ITERABLE_BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Timestamp> TIMESTAMP_TYPE = new TypeToken<>() {};
+
+  // Should not be used directly, only used to check if the proto is stored
+  private static final TypeToken<MessageLite> MESSAGE_TYPE = new TypeToken<>() {};
+
+  public static <I, T> Builder<I, T> builder(String name, TypeToken<T> fieldType) {
+    return new AutoValue_IndexedField.Builder<I, T>()
+        .name(name)
+        .fieldType(fieldType)
+        .stored(false)
+        .required(false);
+  }
+
+  public static <I> Builder<I, Iterable<String>> iterableStringBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_STRING_TYPE);
+  }
+
+  public static <I> Builder<I, String> stringBuilder(String name) {
+    return builder(name, IndexedField.STRING_TYPE);
+  }
+
+  public static <I> Builder<I, Integer> integerBuilder(String name) {
+    return builder(name, IndexedField.INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Long> longBuilder(String name) {
+    return builder(name, IndexedField.LONG_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<Integer>> iterableIntegerBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Timestamp> timestampBuilder(String name) {
+    return builder(name, IndexedField.TIMESTAMP_TYPE);
+  }
+
+  public static <I> Builder<I, byte[]> byteArrayBuilder(String name) {
+    return builder(name, IndexedField.BYTE_ARRAY_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<byte[]>> iterableByteArrayBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_BYTE_ARRAY_TYPE);
+  }
+
+  /**
+   * Defines how {@link IndexedField} can be searched and how the index tokens are generated.
+   *
+   * <p>Multiple {@link SearchSpec} can be defined on a single {@link IndexedField}.
+   *
+   * <p>Depending on the implementation, indexes can choose to store {@link IndexedField} and {@link
+   * SearchSpec} separately. The searches are issues to {@link SearchSpec}.
+   */
+  public class SearchSpec implements SchemaField<I, T> {
+    private final String name;
+    private final SearchOption searchOption;
+
+    public SearchSpec(String name, SearchOption searchOption) {
+      checkName(name);
+      this.name = name;
+      this.searchOption = searchOption;
+    }
+
+    @Override
+    public boolean isStored() {
+      return getField().stored();
+    }
+
+    @Override
+    public boolean isRepeatable() {
+      return getField().repeatable();
+    }
+
+    @Override
+    @Nullable
+    public T get(I obj) {
+      return getField().get(obj);
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public FieldType<?> getType() {
+      SearchOption searchOption = getSearchOption();
+      TypeToken<?> fieldType = getField().fieldType();
+      if (searchOption.equals(SearchOption.STORE_ONLY)) {
+        return FieldType.STORED_ONLY;
+      } else if ((fieldType.equals(IndexedField.INTEGER_TYPE)
+              || fieldType.equals(IndexedField.ITERABLE_INTEGER_TYPE))
+          && searchOption.equals(SearchOption.EXACT)) {
+        return FieldType.INTEGER;
+      } else if (fieldType.equals(IndexedField.INTEGER_TYPE)
+          && searchOption.equals(SearchOption.RANGE)) {
+        return FieldType.INTEGER_RANGE;
+      } else if (fieldType.equals(IndexedField.LONG_TYPE)) {
+        return FieldType.LONG;
+      } else if (fieldType.equals(IndexedField.TIMESTAMP_TYPE)) {
+        return FieldType.TIMESTAMP;
+      } else if (fieldType.equals(IndexedField.STRING_TYPE)
+          || fieldType.equals(IndexedField.ITERABLE_STRING_TYPE)) {
+        if (searchOption.equals(SearchOption.EXACT)) {
+          return FieldType.EXACT;
+        } else if (searchOption.equals(SearchOption.FULL_TEXT)) {
+          return FieldType.FULL_TEXT;
+        } else if (searchOption.equals(SearchOption.PREFIX)) {
+          return FieldType.PREFIX;
+        }
+      }
+      throw new IllegalArgumentException(
+          String.format(
+              "search spec [%s, %s] is not supported on field [%s, %s]",
+              getName(), getSearchOption(), getField().name(), getField().fieldType()));
+    }
+
+    @Override
+    public boolean setIfPossible(I object, StoredValue doc) {
+      return getField().setIfPossible(object, doc);
+    }
+
+    /**
+     * Returns {@link SearchOption} enabled on this field.
+     *
+     * @return {@link SearchOption}
+     */
+    public SearchOption getSearchOption() {
+      return searchOption;
+    }
+
+    /**
+     * Returns {@link IndexedField} on which this spec was created.
+     *
+     * @return original {@link IndexedField} of this spec.
+     */
+    public IndexedField<I, T> getField() {
+      return IndexedField.this;
+    }
+
+    private String checkName(String name) {
+      CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  /**
+   * Adds {@link SearchSpec} to this {@link IndexedField}
+   *
+   * @param name the name to use for in the search.
+   * @param searchOption the tokenization option, enabled by the new {@link SearchSpec}
+   * @return the added {@link SearchSpec}.
+   */
+  public SearchSpec addSearchSpec(String name, SearchOption searchOption) {
+    SearchSpec searchSpec = new SearchSpec(name, searchOption);
+    checkArgument(
+        !searchSpecs.containsKey(searchSpec.getName()),
+        "Can not add search spec %s, because it is already defined on field %s",
+        searchSpec.getName(),
+        name());
+    searchSpecs.put(searchSpec.getName(), searchSpec);
+    return searchSpec;
+  }
+
+  public SearchSpec exact(String name) {
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec fullText(String name) {
+    return addSearchSpec(name, SearchOption.FULL_TEXT);
+  }
+
+  public SearchSpec range(String name) {
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integerRange(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE));
+    // we currently store all integer range fields, this may change in the future
+    checkState(stored());
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integer(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE) || fieldType().equals(ITERABLE_INTEGER_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec longSearch(String name) {
+    checkState(fieldType().equals(LONG_TYPE) || fieldType().equals(ITERABLE_LONG_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec prefix(String name) {
+    return addSearchSpec(name, SearchOption.PREFIX);
+  }
+
+  public SearchSpec storedOnly(String name) {
+    checkState(stored());
+    return addSearchSpec(name, SearchOption.STORE_ONLY);
+  }
+
+  public SearchSpec timestamp(String name) {
+    checkState(fieldType().equals(TIMESTAMP_TYPE));
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  /** A builder for {@link IndexedField}. */
+  @AutoValue.Builder
+  public abstract static class Builder<I, T> {
+
+    public abstract IndexedField.Builder<I, T> name(String name);
+
+    public abstract IndexedField.Builder<I, T> description(Optional<String> description);
+
+    public abstract IndexedField.Builder<I, T> description(String description);
+
+    public abstract Builder<I, T> required(boolean required);
+
+    public Builder<I, T> required() {
+      required(true);
+      return this;
+    }
+
+    /** Allow reading the actual data from the index. */
+    public abstract Builder<I, T> stored(boolean stored);
+
+    public Builder<I, T> stored() {
+      stored(true);
+      return this;
+    }
+
+    abstract Builder<I, T> repeatable(boolean repeatable);
+
+    public abstract Builder<I, T> size(Optional<Integer> value);
+
+    public abstract Builder<I, T> size(Integer value);
+
+    public abstract Builder<I, T> getter(Getter<I, T> getter);
+
+    public abstract Builder<I, T> fieldSetter(Optional<Setter<I, T>> setter);
+
+    abstract TypeToken<T> fieldType();
+
+    public abstract Builder<I, T> fieldType(TypeToken<T> type);
+
+    public abstract Builder<I, T> protoConverter(
+        Optional<ProtoConverter<? extends MessageLite, ?>> value);
+
+    abstract IndexedField<I, T> autoBuild(); // not public
+
+    public final IndexedField<I, T> build() {
+      boolean isRepeatable = fieldType().isSubtypeOf(Iterable.class);
+      repeatable(isRepeatable);
+      IndexedField<I, T> field = autoBuild();
+      checkName(field.name());
+      checkArgument(!field.size().isPresent() || field.size().get() > 0);
+      return field;
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
+      return this.getter(getter).fieldSetter(Optional.of(setter)).build();
+    }
+
+    public final IndexedField<I, T> build(
+        Getter<I, T> getter,
+        Setter<I, T> setter,
+        ProtoConverter<? extends MessageLite, ?> protoConverter) {
+      return this.getter(getter)
+          .fieldSetter(Optional.of(setter))
+          .protoConverter(Optional.of(protoConverter))
+          .build();
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter) {
+      return this.getter(getter).fieldSetter(Optional.empty()).build();
+    }
+
+    private static String checkName(String name) {
+      String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
+      CharMatcher m = CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase());
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  private Map<String, SearchSpec> searchSpecs = new HashMap<>();
+
+  /**
+   * The name to store this field under.
+   *
+   * <p>The name should use the UpperCamelCase format, see {@link Builder#checkName}.
+   */
+  public abstract String name();
+
+  /** Optional description of the field data. */
+  public abstract Optional<String> description();
+
+  /**
+   * True if this field is mandatory. Default is false.
+   *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce that the field is required.
+   */
+  public abstract boolean required();
+
+  /** Allow reading the actual data from the index. Default is false. */
+  public abstract boolean stored();
+
+  /** True if this field is repeatable. */
+  public abstract boolean repeatable();
+
+  /**
+   * Optional size constrain on the field. The size is not constrained if this property is {@link
+   * Optional#empty()}
+   *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce the size.
+   *
+   * <p>If the field is {@link #repeatable()}, the constraint applies to each element separately.
+   */
+  public abstract Optional<Integer> size();
+
+  /** See {@link Getter} */
+  public abstract Getter<I, T> getter();
+
+  /** See {@link Setter} */
+  public abstract Optional<Setter<I, T>> fieldSetter();
+
+  /**
+   * The {@link TypeToken} describing the contents of the field. See static constants for the common
+   * supported types.
+   *
+   * @return {@link TypeToken} of this field.
+   */
+  public abstract TypeToken<T> fieldType();
+
+  /** If the {@link #fieldType()} is proto, the converter to use on byte/proto conversions. */
+  public abstract Optional<ProtoConverter<? extends MessageLite, ?>> protoConverter();
+
+  /**
+   * Returns all {@link SearchSpec}, enabled on this field.
+   *
+   * <p>Note: weather or not a search is supported by the index depends on {@link Schema} version.
+   */
+  public ImmutableMap<String, SearchSpec> getSearchSpecs() {
+    return ImmutableMap.copyOf(searchSpecs);
+  }
+
+  /**
+   * Get the field contents from the input object.
+   *
+   * @param input input object.
+   * @return the field value(s) to index.
+   */
+  @Nullable
+  public T get(I input) {
+    try {
+      return getter().get(input);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public boolean setIfPossible(I object, StoredValue doc) {
+    if (!fieldSetter().isPresent()) {
+      return false;
+    }
+
+    if (this.fieldType().equals(STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asString());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asStrings());
+      return true;
+    } else if (this.fieldType().equals(INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asInteger());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asIntegers());
+      return true;
+    } else if (this.fieldType().equals(LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLong());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLongs());
+      return true;
+    } else if (this.fieldType().equals(BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArray());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArrays());
+      return true;
+    } else if (this.fieldType().equals(TIMESTAMP_TYPE)) {
+      checkState(!repeatable(), "can't repeat timestamp values");
+      fieldSetter().get().set(object, (T) doc.asTimestamp());
+      return true;
+    } else if (isProtoType()) {
+      MessageLite proto = doc.asProto();
+      if (proto != null) {
+        fieldSetter().get().set(object, (T) proto);
+        return true;
+      }
+      byte[] bytes = doc.asByteArray();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) parseProtoFrom(bytes));
+        return true;
+      }
+    } else if (isProtoIterableType()) {
+      Iterable<MessageLite> protos = doc.asProtos();
+      if (protos != null) {
+        fieldSetter().get().set(object, (T) protos);
+        return true;
+      }
+      Iterable<byte[]> bytes = doc.asByteArrays();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) decodeProtos(bytes));
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Returns true if the {@link #fieldType} is a proto message. */
+  public boolean isProtoType() {
+    if (repeatable()) {
+      return false;
+    }
+    return MESSAGE_TYPE.isSupertypeOf(fieldType());
+  }
+
+  /** Returns true if the {@link #fieldType} is a list of proto messages. */
+  public boolean isProtoIterableType() {
+    if (!repeatable()) {
+      return false;
+    }
+    if (!(fieldType().getType() instanceof ParameterizedType)) {
+      return false;
+    }
+    ParameterizedType parameterizedType = (ParameterizedType) fieldType().getType();
+    if (parameterizedType.getActualTypeArguments().length != 1) {
+      return false;
+    }
+    Type type = parameterizedType.getActualTypeArguments()[0];
+    return MESSAGE_TYPE.isSupertypeOf(type);
+  }
+
+  private ImmutableList<MessageLite> decodeProtos(Iterable<byte[]> raw) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(bytes -> parseProtoFrom(bytes))
+        .collect(toImmutableList());
+  }
+
+  private MessageLite parseProtoFrom(byte[] bytes) {
+    return Protos.parseUnchecked(protoConverter().get().getParser(), bytes);
+  }
+}
diff --git a/java/com/google/gerrit/index/PaginationType.java b/java/com/google/gerrit/index/PaginationType.java
new file mode 100644
index 0000000..e7e34fd
--- /dev/null
+++ b/java/com/google/gerrit/index/PaginationType.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+public enum PaginationType {
+  /** Index queries are restarted at a non-zero offset to obtain the next set of results */
+  OFFSET,
+
+  /**
+   * Index queries are restarted using a search-after object. Supported index backends can provide
+   * their custom implementations for search-after.
+   *
+   * <p>For example, Lucene implementation uses the last doc from the previous search as
+   * search-after object and uses the IndexSearcher.searchAfter API to get the next set of results.
+   */
+  SEARCH_AFTER
+}
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index 0401dab..91c8d1a 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -19,15 +19,52 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import java.util.Set;
 import java.util.function.Function;
 
 @AutoValue
 public abstract class QueryOptions {
   public static QueryOptions create(IndexConfig config, int start, int limit, Set<String> fields) {
+    return create(config, start, null, limit, config.pageSizeMultiplier(), limit, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config, int start, int pageSize, int limit, Set<String> fields) {
+    return create(config, start, null, pageSize, config.pageSizeMultiplier(), limit, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> fields) {
+    return create(config, start, null, pageSize, pageSizeMultiplier, limit, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      Object searchAfter,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> fields) {
     checkArgument(start >= 0, "start must be nonnegative: %s", start);
     checkArgument(limit > 0, "limit must be positive: %s", limit);
-    return new AutoValue_QueryOptions(config, start, limit, ImmutableSet.copyOf(fields));
+    if (searchAfter != null) {
+      checkArgument(start == 0, "start must be 0 when searchAfter is specified: %s", start);
+    }
+    return new AutoValue_QueryOptions(
+        config,
+        start,
+        searchAfter,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        ImmutableSet.copyOf(fields));
   }
 
   public QueryOptions convertForBackend() {
@@ -36,26 +73,56 @@
     int backendLimit = config().maxLimit();
     int limit = Ints.saturatedCast((long) limit() + start());
     limit = Math.min(limit, backendLimit);
-    return create(config(), 0, limit, fields());
+    int pageSize = Math.min(Ints.saturatedCast((long) pageSize() + start()), backendLimit);
+    return create(config(), 0, null, pageSize, pageSizeMultiplier(), limit, fields());
   }
 
   public abstract IndexConfig config();
 
   public abstract int start();
 
+  @Nullable
+  public abstract Object searchAfter();
+
+  public abstract int pageSize();
+
+  public abstract int pageSizeMultiplier();
+
   public abstract int limit();
 
   public abstract ImmutableSet<String> fields();
 
+  public QueryOptions withPageSize(int pageSize) {
+    return create(
+        config(), start(), searchAfter(), pageSize, pageSizeMultiplier(), limit(), fields());
+  }
+
   public QueryOptions withLimit(int newLimit) {
-    return create(config(), start(), newLimit, fields());
+    return create(
+        config(), start(), searchAfter(), pageSize(), pageSizeMultiplier(), newLimit, fields());
   }
 
   public QueryOptions withStart(int newStart) {
-    return create(config(), newStart, limit(), fields());
+    return create(
+        config(), newStart, searchAfter(), pageSize(), pageSizeMultiplier(), limit(), fields());
+  }
+
+  public QueryOptions withSearchAfter(Object newSearchAfter) {
+    // Index search-after APIs don't use 'start', so set it to 0 to be safe. ElasticSearch for
+    // example, expects it to be 0 when using search-after APIs.
+    return create(
+            config(), start(), newSearchAfter, pageSize(), pageSizeMultiplier(), limit(), fields())
+        .withStart(0);
   }
 
   public QueryOptions filterFields(Function<QueryOptions, Set<String>> filter) {
-    return create(config(), start(), limit(), filter.apply(this));
+    return create(
+        config(),
+        start(),
+        searchAfter(),
+        pageSize(),
+        pageSizeMultiplier(),
+        limit(),
+        filter.apply(this));
   }
 }
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 91c3f70..25d7cf3 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -14,67 +14,134 @@
 
 package com.google.gerrit.index;
 
+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.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 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.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 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 java.util.Set;
+import java.util.function.Function;
 
 /** Specific version of a secondary index schema. */
 public class Schema<T> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class Builder<T> {
-    private final List<FieldDef<T, ?>> fields = new ArrayList<>();
-    private boolean useLegacyNumericFields;
+    private final List<SchemaField<T, ?>> searchFields = new ArrayList<>();
+    private final List<IndexedField<T, ?>> indexedFields = new ArrayList<>();
+
+    private Optional<Integer> version = Optional.empty();
+
+    public Builder<T> version(int version) {
+      this.version = Optional.of(version);
+      return this;
+    }
 
     public Builder<T> add(Schema<T> schema) {
-      this.fields.addAll(schema.getFields().values());
+      this.indexedFields.addAll(schema.getIndexFields().values());
+      this.searchFields.addAll(schema.getSchemaFields().values());
+      if (!version.isPresent()) {
+        version(schema.getVersion() + 1);
+      }
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> add(FieldDef<T, ?>... fields) {
-      this.fields.addAll(Arrays.asList(fields));
+      return add(ImmutableList.copyOf(fields));
+    }
+
+    public final Builder<T> add(ImmutableList<FieldDef<T, ?>> fields) {
+      this.searchFields.addAll(fields);
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> remove(FieldDef<T, ?>... fields) {
-      this.fields.removeAll(Arrays.asList(fields));
+      this.searchFields.removeAll(Arrays.asList(fields));
       return this;
     }
 
-    public Builder<T> legacyNumericFields(boolean useLegacyNumericFields) {
-      this.useLegacyNumericFields = useLegacyNumericFields;
+    @SafeVarargs
+    public final Builder<T> addSearchSpecs(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      return addSearchSpecs(ImmutableList.copyOf(searchSpecs));
+    }
+
+    public Builder<T> addSearchSpecs(ImmutableList<IndexedField<T, ?>.SearchSpec> searchSpecs) {
+      for (IndexedField<T, ?>.SearchSpec searchSpec : searchSpecs) {
+        checkArgument(
+            this.indexedFields.contains(searchSpec.getField()),
+            "%s spec can only be added to the schema that contains %s field",
+            searchSpec.getName(),
+            searchSpec.getField().name());
+      }
+      this.searchFields.addAll(searchSpecs);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> addIndexedFields(IndexedField<T, ?>... fields) {
+      return addIndexedFields(ImmutableList.copyOf(fields));
+    }
+
+    public Builder<T> addIndexedFields(ImmutableList<IndexedField<T, ?>> indexedFields) {
+      this.indexedFields.addAll(indexedFields);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      this.searchFields.removeAll(Arrays.asList(searchSpecs));
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>... indexedFields) {
+      for (IndexedField<T, ?> field : indexedFields) {
+        ImmutableMap<String, ? extends IndexedField<T, ?>.SearchSpec> searchSpecs =
+            field.getSearchSpecs();
+        checkArgument(
+            !searchSpecs.values().stream().anyMatch(this.searchFields::contains),
+            "Field %s can be only removed from schema after all of its searches are removed.",
+            field.name());
+      }
+      this.indexedFields.removeAll(Arrays.asList(indexedFields));
       return this;
     }
 
     public Schema<T> build() {
-      return new Schema<>(useLegacyNumericFields, ImmutableList.copyOf(fields));
+      checkState(version.isPresent());
+      return new Schema<>(
+          version.get(), ImmutableList.copyOf(indexedFields), ImmutableList.copyOf(searchFields));
     }
   }
 
   public static class Values<T> {
-    private final FieldDef<T, ?> field;
+    private final SchemaField<T, ?> field;
     private final Iterable<?> values;
 
-    private Values(FieldDef<T, ?> field, Iterable<?> values) {
+    private Values(SchemaField<T, ?> field, Iterable<?> values) {
       this.field = field;
       this.values = values;
     }
 
-    public FieldDef<T, ?> getField() {
+    public SchemaField<T, ?> getField() {
       return field;
     }
 
@@ -83,59 +150,70 @@
     }
   }
 
-  private static <T> FieldDef<T, ?> checkSame(FieldDef<T, ?> f1, FieldDef<T, ?> f2) {
+  private static <T> SchemaField<T, ?> checkSame(SchemaField<T, ?> f1, SchemaField<T, ?> f2) {
     checkState(f1 == f2, "Mismatched %s fields: %s != %s", f1.getName(), f1, f2);
     return f1;
   }
 
-  private final ImmutableMap<String, FieldDef<T, ?>> fields;
-  private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
-  private final boolean useLegacyNumericFields;
+  private final ImmutableSet<String> storedFields;
+
+  private final ImmutableMap<String, SchemaField<T, ?>> schemaFields;
+  private final ImmutableMap<String, IndexedField<T, ?>> indexedFields;
 
   private int version;
 
-  public Schema(boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
-    this(0, useLegacyNumericFields, fields);
-  }
-
-  public Schema(int version, boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
+  private Schema(
+      int version,
+      ImmutableList<IndexedField<T, ?>> indexedFields,
+      ImmutableList<SchemaField<T, ?>> schemaFields) {
     this.version = version;
-    ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
-    ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
-    for (FieldDef<T, ?> f : fields) {
-      b.put(f.getName(), f);
-      if (f.isStored()) {
-        sb.put(f.getName(), f);
-      }
-    }
-    this.fields = b.build();
-    this.storedFields = sb.build();
-    this.useLegacyNumericFields = useLegacyNumericFields;
+
+    this.indexedFields =
+        indexedFields.stream().collect(toImmutableMap(IndexedField::name, Function.identity()));
+    this.schemaFields =
+        schemaFields.stream().collect(toImmutableMap(SchemaField::getName, Function.identity()));
+
+    Set<String> duplicateKeys =
+        Sets.intersection(this.schemaFields.keySet(), this.indexedFields.keySet());
+    checkArgument(
+        duplicateKeys.isEmpty(),
+        "DuplicateKeys found %s, indexFields:%s, schemaFields: %s",
+        duplicateKeys,
+        this.indexedFields.keySet(),
+        this.schemaFields.keySet());
+    this.storedFields =
+        schemaFields.stream()
+            .filter(SchemaField::isStored)
+            .map(SchemaField::getName)
+            .collect(toImmutableSet());
   }
 
   public final int getVersion() {
     return version;
   }
 
-  public final boolean useLegacyNumericFields() {
-    return useLegacyNumericFields;
-  }
-
   /**
    * Get all fields in this schema.
    *
    * <p>This is primarily useful for iteration. Most callers should prefer one of the helper methods
-   * {@link #getField(FieldDef, FieldDef...)} or {@link #hasField(FieldDef)} to looking up fields by
-   * name
+   * {@link #getField(SchemaField, SchemaField...)} or {@link #hasField(SchemaField)} to looking up
+   * fields by name
    *
    * @return all fields in this schema indexed by name.
    */
-  public final ImmutableMap<String, FieldDef<T, ?>> getFields() {
-    return fields;
+  public final ImmutableMap<String, SchemaField<T, ?>> getSchemaFields() {
+    return schemaFields;
   }
 
-  /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
-  public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
+  public final ImmutableMap<String, IndexedField<T, ?>> getIndexFields() {
+    return indexedFields;
+  }
+
+  /**
+   * Returns names of {@link SchemaField} fields in this schema where {@link SchemaField#isStored()}
+   * is true.
+   */
+  public final ImmutableSet<String> getStoredFields() {
     return storedFields;
   }
 
@@ -148,13 +226,14 @@
    *     absent if no field matches.
    */
   @SafeVarargs
-  public final Optional<FieldDef<T, ?>> getField(FieldDef<T, ?> first, FieldDef<T, ?>... rest) {
-    FieldDef<T, ?> field = fields.get(first.getName());
+  public final Optional<SchemaField<T, ?>> getField(
+      SchemaField<T, ?> first, SchemaField<T, ?>... rest) {
+    SchemaField<T, ?> field = getSchemaField(first);
     if (field != null) {
       return Optional.of(checkSame(field, first));
     }
-    for (FieldDef<T, ?> f : rest) {
-      field = fields.get(f.getName());
+    for (SchemaField<T, ?> f : rest) {
+      field = getSchemaField(first);
       if (field != null) {
         return Optional.of(checkSame(field, f));
       }
@@ -168,8 +247,8 @@
    * @param field field to look up.
    * @return whether the field is present.
    */
-  public final boolean hasField(FieldDef<T, ?> field) {
-    FieldDef<T, ?> f = fields.get(field.getName());
+  public final boolean hasField(SchemaField<T, ?> field) {
+    SchemaField<T, ?> f = getSchemaField(field);
     if (f == null) {
       return false;
     }
@@ -177,7 +256,20 @@
     return true;
   }
 
-  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+  public final boolean hasField(String fieldName) {
+    return this.getSchemaField(fieldName) != null;
+  }
+
+  private SchemaField<T, ?> getSchemaField(SchemaField<T, ?> field) {
+    return getSchemaField(field.getName());
+  }
+
+  public SchemaField<T, ?> getSchemaField(String fieldName) {
+    return schemaFields.get(fieldName);
+  }
+
+  private @Nullable Values<T> fieldValues(
+      T obj, SchemaField<T, ?> f, ImmutableSet<String> skipFields) {
     if (skipFields.contains(f.getName())) {
       return null;
     }
@@ -215,10 +307,11 @@
    */
   public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
     try {
-      return fields.values().stream()
+      return schemaFields.values().stream()
           .map(f -> fieldValues(obj, f, skipFields))
           .filter(Objects::nonNull)
           .collect(toImmutableList());
+
     } catch (StorageException e) {
       return ImmutableList.of();
     }
@@ -226,10 +319,9 @@
 
   @Override
   public String toString() {
-    return MoreObjects.toStringHelper(this).addValue(fields.keySet()).toString();
-  }
-
-  public void setVersion(int version) {
-    this.version = version;
+    return MoreObjects.toStringHelper(this)
+        .addValue(indexedFields.keySet())
+        .addValue(schemaFields.keySet())
+        .toString();
   }
 }
diff --git a/java/com/google/gerrit/index/SchemaDefinitions.java b/java/com/google/gerrit/index/SchemaDefinitions.java
index e8efd22..4e58d91 100644
--- a/java/com/google/gerrit/index/SchemaDefinitions.java
+++ b/java/com/google/gerrit/index/SchemaDefinitions.java
@@ -42,6 +42,7 @@
     return name;
   }
 
+  /** Returns all schemas sorted by version (ascending). */
   public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
     return schemas;
   }
diff --git a/java/com/google/gerrit/index/SchemaFieldDefs.java b/java/com/google/gerrit/index/SchemaFieldDefs.java
new file mode 100644
index 0000000..db45b8d
--- /dev/null
+++ b/java/com/google/gerrit/index/SchemaFieldDefs.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.gerrit.common.Nullable;
+import java.io.IOException;
+
+/** Interfaces that define properties of fields in {@link Schema}. */
+public class SchemaFieldDefs {
+
+  /**
+   * Definition of a field stored in the secondary index.
+   *
+   * <p>{@link SchemaField}-s must not be changed once introduced to the codebase. Instead, a new
+   * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see
+   * {@code com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> input type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting to an index
+   *     document.
+   */
+  public interface SchemaField<T, I> {
+
+    /** Returns whether the field should be stored in the index. */
+    boolean isStored();
+
+    /** Returns whether the field is repeatable. */
+    boolean isRepeatable();
+
+    /**
+     * Get the field contents from the input object.
+     *
+     * @param input input object.
+     * @return the field value(s) to index.
+     */
+    @Nullable
+    I get(T input);
+
+    /** Returns the name of the field. */
+    String getName();
+
+    /**
+     * Returns type of the field; for repeatable fields, the inner type, not the iterable type.
+     * TODO(mariasavtchuk): remove after migrating to the new field formats
+     */
+    FieldType<?> getType();
+
+    /**
+     * Set the field contents back to an object. Used to reconstruct fields from indexed values.
+     * No-op if the field can't be reconstructed.
+     *
+     * @param object input object.
+     * @param doc indexed document
+     * @return {@code true} if the field was set, {@code false} otherwise
+     */
+    boolean setIfPossible(T object, StoredValue doc);
+  }
+
+  /**
+   * Getter to extract value that should be stored in index from the input object.
+   *
+   * <p>This interface allows to specify a method or lambda for populating an index field. Note that
+   * for existing fields, changing the code of either the {@link Getter} implementation or the
+   * method(s) that it calls would invalidate existing index data. Therefore, instead of changing
+   * the semantics of an existing field, a new field must be added using the new semantics from the
+   * start. The old field can be removed in another upgrade step (cf. {@code
+   * com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object to an index field.
+   */
+  @FunctionalInterface
+  public interface Getter<I, T> {
+    @Nullable
+    T get(I input) throws IOException;
+  }
+
+  /**
+   * Setter to reconstruct fields from indexed values back to an object.
+   *
+   * <p>See {@link Getter} for restrictions on changing the implementation.
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting toto an index
+   *     field.
+   */
+  @FunctionalInterface
+  public interface Setter<I, T> {
+    void set(I object, T value);
+  }
+
+  public static boolean isProtoField(SchemaField<?, ?> schemaField) {
+    if (!(schemaField instanceof IndexedField<?, ?>.SearchSpec)) {
+      return false;
+    }
+    IndexedField<?, ?> indexedField = ((IndexedField<?, ?>.SearchSpec) schemaField).getField();
+    return indexedField.isProtoType() || indexedField.isProtoIterableType();
+  }
+}
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 9599d6a..079f8be 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -25,7 +25,6 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.ParameterizedType;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -49,7 +48,12 @@
             @SuppressWarnings("unchecked")
             Schema<V> schema = (Schema<V>) f.get(null);
             checkArgument(f.getName().startsWith("V"));
-            schema.setVersion(Integer.parseInt(f.getName().substring(1)));
+            int versionName = Integer.parseInt(f.getName().substring(1));
+            checkArgument(
+                versionName == schema.getVersion(),
+                "Schema version %s does not match its name %s",
+                schema.getVersion(),
+                f.getName());
             schemas.put(schema.getVersion(), schema);
           } catch (IllegalAccessException e) {
             throw new IllegalArgumentException(e);
@@ -66,29 +70,78 @@
     return ImmutableSortedMap.copyOf(schemas);
   }
 
-  public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  @SafeVarargs
+  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(0).add(fields).build();
   }
 
-  public static <V> Schema<V> schema(Schema<V> schema, boolean useLegacyNumericFields) {
-    return new Schema<>(
-        useLegacyNumericFields,
-        new ImmutableList.Builder<FieldDef<V, ?>>().addAll(schema.getFields().values()).build());
+  @SafeVarargs
+  public static <V> Schema<V> schema(int version, FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
+  }
+
+  public static <V> Schema<V> schema(int version, ImmutableList<FieldDef<V, ?>> fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
   }
 
   @SafeVarargs
   public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
-    return new Schema<>(
-        true,
-        new ImmutableList.Builder<FieldDef<V, ?>>()
-            .addAll(schema.getFields().values())
-            .addAll(ImmutableList.copyOf(moreFields))
-            .build());
+    return new Schema.Builder<V>().add(schema).add(moreFields).build();
   }
 
-  @SafeVarargs
-  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  public static <V> Schema<V> schema(
+      int version,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .version(version)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      int version,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .version(version)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      Schema<V> schema,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .add(schema)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      Schema<V> schema,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .add(schema)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return schema(/* version= */ 0, fieldDefs, indexFields, searchSpecs);
   }
 
   public static Set<String> getPersonParts(PersonIdent person) {
diff --git a/java/com/google/gerrit/index/SearchOption.java b/java/com/google/gerrit/index/SearchOption.java
new file mode 100644
index 0000000..3fbb68a
--- /dev/null
+++ b/java/com/google/gerrit/index/SearchOption.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+/** Tokenization options enabled on {@link IndexedField}. */
+public enum SearchOption {
+  /** Enables range queries on the field. */
+  RANGE,
+  /** Enables prefix-match search on the field. */
+  PREFIX,
+  /** Enables exact-match search on the field. */
+  EXACT,
+  /** Enables fuzzy-match search on the field. */
+  FULL_TEXT,
+  /** The field can not be searched and is only returned as a payload from the index. */
+  STORE_ONLY,
+}
diff --git a/java/com/google/gerrit/index/StoredValue.java b/java/com/google/gerrit/index/StoredValue.java
index fe790c5..a7e7c26 100644
--- a/java/com/google/gerrit/index/StoredValue.java
+++ b/java/com/google/gerrit/index/StoredValue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.index;
 
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /**
@@ -47,4 +49,22 @@
 
   /** Returns the {@code byte[]} values of the field. */
   Iterable<byte[]> asByteArrays();
+
+  /**
+   * Returns the {@code MessageLite} value of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArray} can be called instead to obtain the value.
+   */
+  @Nullable
+  MessageLite asProto();
+
+  /**
+   * Returns the {@code MessageLite} values of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArrays} can be called instead to obtain the value.
+   */
+  @Nullable
+  Iterable<MessageLite> asProtos();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index c2c8986..e050f53 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -15,41 +15,74 @@
 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.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 
-/** Index schema for projects. */
+/**
+ * Index schema for projects.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 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 IndexedField<ProjectData, String> NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("RepoName")
+          .required()
+          .size(200)
+          .stored()
+          .build(p -> p.getProject().getName());
 
-  public static final FieldDef<ProjectData, String> DESCRIPTION =
-      fullText("description").stored().build(p -> p.getProject().getDescription());
+  public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
-  public static final FieldDef<ProjectData, String> PARENT_NAME =
-      exact("parent_name").build(p -> p.getProject().getParentName());
+  public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
+      IndexedField.<ProjectData>stringBuilder("Description")
+          .stored()
+          .build(p -> p.getProject().getDescription());
 
-  public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+  public static final IndexedField<ProjectData, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
-  public static final FieldDef<ProjectData, String> STATE =
-      exact("state").stored().build(p -> p.getProject().getState().name());
+  public static final IndexedField<ProjectData, String> PARENT_NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("ParentName")
+          .build(p -> p.getProject().getParentName());
 
-  public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
-      exact("ancestor_name").buildRepeatable(ProjectData::getParentNames);
+  public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
+      PARENT_NAME_FIELD.exact("parent_name");
+
+  public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("NamePart")
+          .size(200)
+          .build(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
+
+  public static final IndexedField<ProjectData, String> STATE_FIELD =
+      IndexedField.<ProjectData>stringBuilder("State")
+          .stored()
+          .build(p -> p.getProject().getState().name());
+
+  public static final IndexedField<ProjectData, String>.SearchSpec STATE_SPEC =
+      STATE_FIELD.exact("state");
+
+  public static final IndexedField<ProjectData, Iterable<String>> ANCESTOR_NAME_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("AncestorName")
+          .build(ProjectData::getParentNames);
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec ANCESTOR_NAME_SPEC =
+      ANCESTOR_NAME_FIELD.exact("ancestor_name");
 
   /**
    * All values of all refs that were used in the course of indexing this document. This covers
diff --git a/java/com/google/gerrit/index/project/ProjectIndex.java b/java/com/google/gerrit/index/project/ProjectIndex.java
index b2ddaff..0aa7393 100644
--- a/java/com/google/gerrit/index/project/ProjectIndex.java
+++ b/java/com/google/gerrit/index/project/ProjectIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit projects (repositories). This class is mainly used for typing the generic parent
@@ -30,6 +31,8 @@
 
   @Override
   default Predicate<ProjectData> keyPredicate(Project.NameKey nameKey) {
-    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+    return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
+
+  Function<ProjectData, Project.NameKey> ENTITY_TO_KEY = (p) -> p.getProject().getNameKey();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectPredicate.java b/java/com/google/gerrit/index/project/ProjectPredicate.java
index 11875ef..0eaf2b6 100644
--- a/java/com/google/gerrit/index/project/ProjectPredicate.java
+++ b/java/com/google/gerrit/index/project/ProjectPredicate.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.index.project;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 
 /** Predicate that is mapped to a field in the project index. */
 public class ProjectPredicate extends IndexPredicate<ProjectData> {
-  public ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
+  public ProjectPredicate(SchemaField<ProjectData, ?> def, String value) {
     super(def, value);
   }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3cc5f9b..05c23e1 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -16,29 +16,52 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of project index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of project index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
 
   @Deprecated
   static final Schema<ProjectData> V1 =
       schema(
-          ProjectField.NAME,
-          ProjectField.DESCRIPTION,
-          ProjectField.PARENT_NAME,
-          ProjectField.NAME_PART,
-          ProjectField.ANCESTOR_NAME);
+          /* version= */ 1,
+          ImmutableList.of(
+              ProjectField.NAME_FIELD,
+              ProjectField.DESCRIPTION_FIELD,
+              ProjectField.PARENT_NAME_FIELD,
+              ProjectField.NAME_PART_FIELD,
+              ProjectField.ANCESTOR_NAME_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(
+              ProjectField.NAME_SPEC,
+              ProjectField.DESCRIPTION_SPEC,
+              ProjectField.PARENT_NAME_SPEC,
+              ProjectField.NAME_PART_SPEC,
+              ProjectField.ANCESTOR_NAME_SPEC));
 
   @Deprecated
-  static final Schema<ProjectData> V2 = schema(V1, ProjectField.STATE, ProjectField.REF_STATE);
+  static final Schema<ProjectData> V2 =
+      schema(
+          V1,
+          ImmutableList.of(ProjectField.REF_STATE),
+          ImmutableList.<IndexedField<ProjectData, ?>>of(ProjectField.STATE_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(ProjectField.STATE_SPEC));
 
   // 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);
+  @Deprecated static final Schema<ProjectData> V4 = schema(V3);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<ProjectData> V5 = schema(V4);
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/query/AndCardinalPredicate.java b/java/com/google/gerrit/index/query/AndCardinalPredicate.java
new file mode 100644
index 0000000..cf0e8c3
--- /dev/null
+++ b/java/com/google/gerrit/index/query/AndCardinalPredicate.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 java.util.Collection;
+import java.util.Optional;
+
+public class AndCardinalPredicate<T> extends AndPredicate<T> implements HasCardinality {
+  private final int cardinality;
+
+  public AndCardinalPredicate(Collection<? extends Predicate<T>> that) {
+    super(that);
+    Optional<Predicate<T>> atLeastOneCardinalPredicate =
+        getChildren().stream().filter(p -> (p instanceof HasCardinality)).findAny();
+    if (!atLeastOneCardinalPredicate.isPresent()) {
+      throw new IllegalArgumentException("No HasCardinality Found");
+    }
+    int minCardinality = Integer.MAX_VALUE;
+    for (Predicate<T> child : getChildren()) {
+      if (child instanceof HasCardinality) {
+        minCardinality = Math.min(((HasCardinality) child).getCardinality(), minCardinality);
+      }
+    }
+    cardinality = minCardinality;
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return new AndCardinalPredicate<>(children);
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index ae13fb3..fda961d 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -15,15 +15,19 @@
 package com.google.gerrit.index.query;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 
 /** Requires all predicates to be true. */
-public class AndPredicate<T> extends Predicate<T> implements Matchable<T> {
+public class AndPredicate<T> extends Predicate<T>
+    implements Matchable<T>, Comparator<Predicate<T>> {
   private final List<Predicate<T>> children;
   private final int cost;
 
@@ -35,7 +39,7 @@
   protected AndPredicate(Collection<? extends Predicate<T>> that) {
     List<Predicate<T>> t = new ArrayList<>(that.size());
     int c = 0;
-    for (Predicate<T> p : that) {
+    for (Predicate<T> p : sort(that)) {
       if (getClass() == p.getClass()) {
         for (Predicate<T> gp : p.getChildren()) {
           t.add(gp);
@@ -105,6 +109,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
@@ -114,6 +120,28 @@
         && getChildren().equals(((Predicate<?>) other).getChildren());
   }
 
+  private ImmutableList<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
+    return that.stream().sorted(this).collect(toImmutableList());
+  }
+
+  @Override
+  public int compare(Predicate<T> a, Predicate<T> b) {
+    int ai = a instanceof DataSource ? 0 : 1;
+    int bi = b instanceof DataSource ? 0 : 1;
+    int cmp = ai - bi;
+
+    if (cmp == 0) {
+      cmp = a.estimateCost() - b.estimateCost();
+    }
+
+    if (cmp == 0 && a instanceof DataSource) {
+      DataSource<?> as = (DataSource<?>) a;
+      DataSource<?> bs = (DataSource<?>) b;
+      cmp = as.getCardinality() - bs.getCardinality();
+    }
+    return cmp;
+  }
+
   @Override
   public String toString() {
     final StringBuilder r = new StringBuilder();
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 538e11b..f5f30bd 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -15,124 +15,81 @@
 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.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.exceptions.StorageException;
-import java.util.ArrayList;
+import com.google.gerrit.index.IndexConfig;
 import java.util.Collection;
-import java.util.Comparator;
 import java.util.List;
 
-public class AndSource<T> extends AndPredicate<T>
-    implements DataSource<T>, Comparator<Predicate<T>> {
+public class AndSource<T> extends AndPredicate<T> implements DataSource<T> {
   protected final DataSource<T> source;
 
   private final IsVisibleToPredicate<T> isVisibleToPredicate;
   private final int start;
   private final int cardinality;
+  private final IndexConfig indexConfig;
 
-  public AndSource(Collection<? extends Predicate<T>> that) {
-    this(that, null, 0);
+  public AndSource(Collection<? extends Predicate<T>> that, IndexConfig indexConfig) {
+    this(that, null, 0, indexConfig);
   }
 
-  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate) {
-    this(that, isVisibleToPredicate, 0);
+  public AndSource(
+      Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, IndexConfig indexConfig) {
+    this(that, isVisibleToPredicate, 0, indexConfig);
   }
 
-  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
-    this(ImmutableList.of(that), isVisibleToPredicate, start);
+  public AndSource(
+      Predicate<T> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate,
+      int start,
+      IndexConfig indexConfig) {
+    this(ImmutableList.of(that), isVisibleToPredicate, start, indexConfig);
   }
 
   public AndSource(
       Collection<? extends Predicate<T>> that,
       IsVisibleToPredicate<T> isVisibleToPredicate,
-      int start) {
+      int start,
+      IndexConfig indexConfig) {
     super(that);
     checkArgument(start >= 0, "negative start: %s", start);
     this.isVisibleToPredicate = isVisibleToPredicate;
     this.start = start;
+    this.indexConfig = indexConfig;
 
     int c = Integer.MAX_VALUE;
-    DataSource<T> s = null;
-    int minCost = Integer.MAX_VALUE;
-    for (Predicate<T> p : sort(getChildren())) {
+    Predicate<T> selectedSource = null;
+    int minCardinality = Integer.MAX_VALUE;
+    for (Predicate<T> p : getChildren()) {
       if (p instanceof DataSource) {
-        c = Math.min(c, ((DataSource<?>) p).getCardinality());
+        DataSource<?> source = (DataSource<?>) p;
+        int cardinality = source.getCardinality();
+        c = Math.min(c, source.getCardinality());
 
-        int cost = p.estimateCost();
-        if (cost < minCost) {
-          s = toDataSource(p);
-          minCost = cost;
+        if (selectedSource == null
+            || cardinality < minCardinality
+            || (cardinality == minCardinality
+                && p.estimateCost() < selectedSource.estimateCost())) {
+          selectedSource = p;
+          minCardinality = cardinality;
         }
       }
     }
-    this.source = s;
+    if (selectedSource == null) {
+      throw new IllegalArgumentException("No DataSource Found");
+    }
+    this.source = toPaginatingSource(selectedSource);
     this.cardinality = c;
   }
 
   @Override
   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);
-        });
+    return source.read();
   }
 
   @Override
   public ResultSet<FieldBundle> readRaw() {
-    // TOOD(hiesel): Implement
-    throw new UnsupportedOperationException("not implemented");
+    return source.readRaw();
   }
 
   @Override
@@ -153,11 +110,6 @@
     return true;
   }
 
-  private Iterable<T> buffer(ResultSet<T> scanner) {
-    return FluentIterable.from(Iterables.partition(scanner, 50))
-        .transformAndConcat(this::transformBuffer);
-  }
-
   protected List<T> transformBuffer(List<T> buffer) {
     return buffer;
   }
@@ -167,30 +119,18 @@
     return cardinality;
   }
 
-  private ImmutableList<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
-    return that.stream().sorted(this).collect(toImmutableList());
-  }
-
-  @Override
-  public int compare(Predicate<T> a, Predicate<T> b) {
-    int ai = a instanceof DataSource ? 0 : 1;
-    int bi = b instanceof DataSource ? 0 : 1;
-    int cmp = ai - bi;
-
-    if (cmp == 0) {
-      cmp = a.estimateCost() - b.estimateCost();
-    }
-
-    if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
-      DataSource<?> as = (DataSource<?>) a;
-      DataSource<?> bs = (DataSource<?>) b;
-      cmp = as.getCardinality() - bs.getCardinality();
-    }
-    return cmp;
-  }
-
   @SuppressWarnings("unchecked")
-  private DataSource<T> toDataSource(Predicate<T> pred) {
-    return (DataSource<T>) pred;
+  private PaginatingSource<T> toPaginatingSource(Predicate<T> pred) {
+    return new PaginatingSource<>((DataSource<T>) pred, start, indexConfig) {
+      @Override
+      protected boolean match(T object) {
+        return AndSource.this.match(object);
+      }
+
+      @Override
+      protected boolean isMatchable() {
+        return AndSource.this.isMatchable();
+      }
+    };
   }
 }
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index 518d153..3b83478 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.index.query;
 
-public interface DataSource<T> {
-  /** Returns an estimate of the number of results from {@link #read()}. */
-  int getCardinality();
-
-  /** Returns read from the database and return the results. */
+public interface DataSource<T> extends HasCardinality {
+  /** Returns read from the index and return the results. */
   ResultSet<T> read();
 
-  /** Returns read from the database and return the raw results. */
+  /** Returns read from the index and return the raw results. */
   ResultSet<FieldBundle> readRaw();
 }
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 6ecb6e6..551de92 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -16,10 +16,13 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+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.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.IndexedField.SearchSpec;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
 public class FieldBundle {
@@ -27,15 +30,29 @@
   // Map String => List{Integer, Long, Timestamp, String, byte[]}
   private ImmutableListMultimap<String, Object> fields;
 
-  public FieldBundle(ListMultimap<String, Object> fields) {
+  /**
+   * Depending on the index implementation 1) either {@link IndexedField} are stored once and
+   * referenced by {@link com.google.gerrit.index.IndexedField.SearchSpec} on the queries, 2) or
+   * each {@link com.google.gerrit.index.IndexedField.SearchSpec} is stored individually.
+   *
+   * <p>In case #1 {@link #storesIndexedFields} is set to {@code true} and the {@link #fields}
+   * contain a map from {@link IndexedField#name()} to a stored value.
+   *
+   * <p>In case #2 {@link #storesIndexedFields} is set to {@code false} and the {@link #fields}
+   * contain a map from {@link SearchSpec#name()} to a stored value.
+   */
+  private final boolean storesIndexedFields;
+
+  public FieldBundle(ListMultimap<String, Object> fields, boolean storesIndexedFields) {
     this.fields = ImmutableListMultimap.copyOf(fields);
+    this.storesIndexedFields = storesIndexedFields;
   }
 
   /**
    * Get a field's value based on the field definition.
    *
-   * @param fieldDef the definition of the field of which the value should be retrieved. The field
-   *     must be stored and contained in the result set as specified by {@link
+   * @param schemaField the definition of the field of which the value should be retrieved. The
+   *     field must be stored and contained in the result set as specified by {@link
    *     com.google.gerrit.index.QueryOptions}.
    * @param <T> Data type of the returned object based on the field definition
    * @return Either a single element or an Iterable based on the field definition. An empty list is
@@ -44,16 +61,20 @@
    *     check is only enforced on non-repeatable fields.
    */
   @SuppressWarnings("unchecked")
-  public <T> T getValue(FieldDef<?, T> fieldDef) {
-    checkArgument(fieldDef.isStored(), "Field must be stored");
+  public <T> T getValue(SchemaField<?, T> schemaField) {
+    checkArgument(schemaField.isStored(), "Field must be stored");
+    String storedFieldName =
+        storesIndexedFields && schemaField instanceof IndexedField<?, ?>.SearchSpec
+            ? ((IndexedField<?, ?>.SearchSpec) schemaField).getField().name()
+            : schemaField.getName();
     checkArgument(
-        fields.containsKey(fieldDef.getName()) || fieldDef.isRepeatable(),
+        fields.containsKey(storedFieldName) || schemaField.isRepeatable(),
         "Field %s is not in result set %s",
-        fieldDef.getName(),
+        storedFieldName,
         fields.keySet());
 
-    Iterable<Object> result = fields.get(fieldDef.getName());
-    if (fieldDef.isRepeatable()) {
+    ImmutableList<Object> result = fields.get(storedFieldName);
+    if (schemaField.isRepeatable()) {
       return (T) result;
     }
     return (T) Iterables.getOnlyElement(result);
diff --git a/java/com/google/gerrit/index/query/HasCardinality.java b/java/com/google/gerrit/index/query/HasCardinality.java
new file mode 100644
index 0000000..140ba4b
--- /dev/null
+++ b/java/com/google/gerrit/index/query/HasCardinality.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
+
+public interface HasCardinality {
+  /** Returns an estimate of the number of results a source can return. */
+  int getCardinality();
+}
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 18d7fbc..de81c47 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -21,8 +21,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.StreamSupport;
@@ -35,21 +35,21 @@
    * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
    * spec.
    */
-  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_\n"));
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_=\n"));
 
-  private final FieldDef<I, ?> def;
+  private final SchemaField<I, ?> def;
 
-  protected IndexPredicate(FieldDef<I, ?> def, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String value) {
     super(def.getName(), value);
     this.def = def;
   }
 
-  protected IndexPredicate(FieldDef<I, ?> def, String name, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String name, String value) {
     super(name, value);
     this.def = def;
   }
 
-  public FieldDef<I, ?> getField() {
+  public SchemaField<I, ?> getField() {
     return def;
   }
 
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index d9e33ea..ee25ef9 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -87,19 +87,15 @@
   }
 
   @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();
+  public ResultSet<T> restart(int start, int pageSize) {
+    opts = opts.withStart(start).withPageSize(pageSize);
+    return search();
+  }
+
+  @Override
+  public ResultSet<T> restart(Object searchAfter, int pageSize) {
+    opts = opts.withSearchAfter(searchAfter).withPageSize(pageSize);
+    return search();
   }
 
   @Override
@@ -112,6 +108,8 @@
     return pred.hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null || getClass() != other.getClass()) {
@@ -125,4 +123,18 @@
   public String toString() {
     return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
   }
+
+  private ResultSet<T> search() {
+    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();
+  }
 }
diff --git a/java/com/google/gerrit/index/query/IntPredicate.java b/java/com/google/gerrit/index/query/IntPredicate.java
index 16e59e7..a98e0b1 100644
--- a/java/com/google/gerrit/index/query/IntPredicate.java
+++ b/java/com/google/gerrit/index/query/IntPredicate.java
@@ -37,6 +37,8 @@
     return getOperator().hashCode() * 31 + intValue;
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 850c4a5..278d2af 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.RangeUtil.Range;
 
 public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
   private final Range range;
 
-  protected IntegerRangePredicate(FieldDef<T, Integer> type, String value)
+  protected IntegerRangePredicate(SchemaField<T, Integer> type, String value)
       throws QueryParseException {
     super(type, value);
     range = RangeUtil.getRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE);
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 5c003bc..b6418a9 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -20,12 +20,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 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.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Arrays;
 import java.util.List;
 import java.util.function.Supplier;
@@ -76,10 +77,10 @@
   }
 
   @SafeVarargs
-  public final Q setRequestedFields(FieldDef<T, ?>... fields) {
+  public final Q setRequestedFields(SchemaField<T, ?>... fields) {
     checkArgument(fields.length > 0, "requested field list is empty");
     queryProcessor.setRequestedFields(
-        Arrays.stream(fields).map(FieldDef::getName).collect(toSet()));
+        Arrays.stream(fields).map(SchemaField::getName).collect(toSet()));
     return self();
   }
 
@@ -118,6 +119,7 @@
     }
   }
 
+  @Nullable
   protected final Schema<T> schema() {
     Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
     return index != null ? index.getSchema() : null;
diff --git a/java/com/google/gerrit/index/query/LazyResultSet.java b/java/com/google/gerrit/index/query/LazyResultSet.java
index f3fab5f..a7d71f0 100644
--- a/java/com/google/gerrit/index/query/LazyResultSet.java
+++ b/java/com/google/gerrit/index/query/LazyResultSet.java
@@ -53,4 +53,9 @@
 
   @Override
   public void close() {}
+
+  @Override
+  public Object searchAfter() {
+    return null;
+  }
 }
diff --git a/java/com/google/gerrit/index/query/LimitPredicate.java b/java/com/google/gerrit/index/query/LimitPredicate.java
index 23e0f6d..9196811 100644
--- a/java/com/google/gerrit/index/query/LimitPredicate.java
+++ b/java/com/google/gerrit/index/query/LimitPredicate.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.common.Nullable;
+
 public class LimitPredicate<T> extends IntPredicate<T> implements Matchable<T> {
   @SuppressWarnings("unchecked")
+  @Nullable
   public static Integer getLimit(String fieldName, Predicate<?> p) {
     IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
     return ip != null ? ip.intValue() : null;
diff --git a/java/com/google/gerrit/index/query/ListResultSet.java b/java/com/google/gerrit/index/query/ListResultSet.java
index 9d7eadf..f09fda0 100644
--- a/java/com/google/gerrit/index/query/ListResultSet.java
+++ b/java/com/google/gerrit/index/query/ListResultSet.java
@@ -54,4 +54,9 @@
   public void close() {
     results = null;
   }
+
+  @Override
+  public Object searchAfter() {
+    return null;
+  }
 }
diff --git a/java/com/google/gerrit/index/query/NotPredicate.java b/java/com/google/gerrit/index/query/NotPredicate.java
index 14cb740..fa8e01b 100644
--- a/java/com/google/gerrit/index/query/NotPredicate.java
+++ b/java/com/google/gerrit/index/query/NotPredicate.java
@@ -21,10 +21,10 @@
 import java.util.List;
 
 /** Negates the result of another predicate. */
-public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
+public final class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final Predicate<T> that;
 
-  protected NotPredicate(Predicate<T> that) {
+  NotPredicate(Predicate<T> that) {
     if (that instanceof NotPredicate) {
       throw new IllegalArgumentException("Double negation unsupported");
     }
@@ -87,7 +87,7 @@
     if (other == null) {
       return false;
     }
-    return getClass() == other.getClass()
+    return other instanceof NotPredicate
         && getChildren().equals(((Predicate<?>) other).getChildren());
   }
 
diff --git a/java/com/google/gerrit/index/query/OperatorPredicate.java b/java/com/google/gerrit/index/query/OperatorPredicate.java
index 368ee24..ea7717f 100644
--- a/java/com/google/gerrit/index/query/OperatorPredicate.java
+++ b/java/com/google/gerrit/index/query/OperatorPredicate.java
@@ -47,6 +47,8 @@
     return getOperator().hashCode() * 31 + getValue().hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/OrCardinalPredicate.java b/java/com/google/gerrit/index/query/OrCardinalPredicate.java
new file mode 100644
index 0000000..9d7913a
--- /dev/null
+++ b/java/com/google/gerrit/index/query/OrCardinalPredicate.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 java.util.Collection;
+import java.util.Optional;
+
+public class OrCardinalPredicate<T> extends OrPredicate<T> implements HasCardinality {
+  private final int cardinality;
+
+  public OrCardinalPredicate(Collection<? extends Predicate<T>> that) {
+    super(that);
+    Optional<Predicate<T>> nonHasCardinality =
+        getChildren().stream().filter(p -> !(p instanceof HasCardinality)).findAny();
+    if (nonHasCardinality.isPresent()) {
+      throw new IllegalArgumentException("No HasCardinality: " + nonHasCardinality.get());
+    }
+    int aggregateCardinality = 0;
+    for (Predicate<T> p : getChildren()) {
+      if (p instanceof HasCardinality) {
+        aggregateCardinality += ((HasCardinality) p).getCardinality();
+      }
+    }
+    cardinality = aggregateCardinality;
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return new OrCardinalPredicate<>(children);
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
index 9bc3769..1c31af3 100644
--- a/java/com/google/gerrit/index/query/OrPredicate.java
+++ b/java/com/google/gerrit/index/query/OrPredicate.java
@@ -105,6 +105,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/Paginated.java b/java/com/google/gerrit/index/query/Paginated.java
index e61dd53..5521990 100644
--- a/java/com/google/gerrit/index/query/Paginated.java
+++ b/java/com/google/gerrit/index/query/Paginated.java
@@ -19,5 +19,7 @@
 public interface Paginated<T> {
   QueryOptions getOptions();
 
-  ResultSet<T> restart(int start);
+  ResultSet<T> restart(int start, int pageSize);
+
+  ResultSet<T> restart(Object searchAfter, int pageSize);
 }
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
new file mode 100644
index 0000000..337332f
--- /dev/null
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.base.Preconditions.checkArgument;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.PaginationType;
+import com.google.gerrit.index.QueryOptions;
+import java.util.ArrayList;
+import java.util.List;
+
+public class PaginatingSource<T> implements DataSource<T> {
+  protected final DataSource<T> source;
+  private final int start;
+  private final int cardinality;
+  private final IndexConfig indexConfig;
+
+  public PaginatingSource(DataSource<T> source, int start, IndexConfig indexConfig) {
+    checkArgument(start >= 0, "negative start: %s", start);
+    this.source = source;
+    this.start = start;
+    this.cardinality = source.getCardinality();
+    this.indexConfig = indexConfig;
+  }
+
+  @Override
+  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 pageResultSize = 0;
+          for (T data : buffer(resultSet)) {
+            if (!isMatchable() || match(data)) {
+              r.add(data);
+            }
+            last = data;
+            pageResultSize++;
+          }
+
+          if (last != null && source instanceof Paginated) {
+            // Restart source and continue if we have not filled the
+            // full limit the caller wants.
+            //
+            @SuppressWarnings("unchecked")
+            Paginated<T> p = (Paginated<T>) source;
+            QueryOptions opts = p.getOptions();
+            final int limit = opts.limit();
+            int pageSize = opts.pageSize();
+            int pageSizeMultiplier = opts.pageSizeMultiplier();
+            Object searchAfter = resultSet.searchAfter();
+            int nextStart = pageResultSize;
+            while (pageResultSize == pageSize && r.size() <= limit) { // get 1 more than the limit
+              pageSize = getNextPageSize(pageSize, pageSizeMultiplier);
+              ResultSet<T> next =
+                  indexConfig.paginationType().equals(PaginationType.SEARCH_AFTER)
+                      ? p.restart(searchAfter, pageSize)
+                      : p.restart(nextStart, pageSize);
+              pageResultSize = 0;
+              for (T data : buffer(next)) {
+                if (match(data)) {
+                  r.add(data);
+                }
+                pageResultSize++;
+              }
+              nextStart += pageResultSize;
+              searchAfter = next.searchAfter();
+            }
+          }
+
+          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() {
+    // TODO(hiesel): Implement
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  private Iterable<T> buffer(ResultSet<T> scanner) {
+    return FluentIterable.from(Iterables.partition(scanner, 50))
+        .transformAndConcat(this::transformBuffer);
+  }
+
+  /**
+   * Checks whether the given object matches.
+   *
+   * @param object the object to be matched
+   * @return whether the given object matches
+   */
+  protected boolean match(T object) {
+    return true;
+  }
+
+  protected boolean isMatchable() {
+    return true;
+  }
+
+  protected List<T> transformBuffer(List<T> buffer) {
+    return buffer;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+
+  private int getNextPageSize(int pageSize, int pageSizeMultiplier) {
+    List<Integer> possiblePageSizes = new ArrayList<>(3);
+    try {
+      possiblePageSizes.add(Math.multiplyExact(pageSize, pageSizeMultiplier));
+    } catch (ArithmeticException e) {
+      possiblePageSizes.add(Integer.MAX_VALUE);
+    }
+    if (indexConfig.maxPageSize() > 0) {
+      possiblePageSizes.add(indexConfig.maxPageSize());
+    }
+    if (indexConfig.maxLimit() > 0) {
+      possiblePageSizes.add(indexConfig.maxLimit());
+    }
+    return Ordering.natural().min(possiblePageSizes);
+  }
+}
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index 9dc7689..e251b00 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -18,10 +18,10 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.Iterables;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
 
@@ -152,7 +152,7 @@
   /** Returns a list of this predicate and all its descendants. */
   public List<Predicate<T>> getFlattenedPredicateList() {
     List<Predicate<T>> result = new ArrayList<>();
-    Queue<Predicate<T>> queue = new LinkedList<>();
+    Queue<Predicate<T>> queue = new ArrayDeque<>();
     queue.add(this);
     while (!queue.isEmpty()) {
       Predicate<T> current = queue.poll();
diff --git a/java/com/google/gerrit/index/query/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
index ffa7ce4..987c7d3 100644
--- a/java/com/google/gerrit/index/query/QueryBuilder.java
+++ b/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -31,6 +31,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.antlr.runtime.CharStream;
 import org.antlr.runtime.CommonToken;
 import org.antlr.runtime.tree.CommonTree;
@@ -364,7 +366,7 @@
    * @throws QueryParseException the parser does not recognize this value.
    */
   protected Predicate<T> defaultField(String value) throws QueryParseException {
-    throw error("Unsupported query:" + value);
+    throw error("Unsupported query: " + value);
   }
 
   private List<Predicate<T>> children(Tree r) throws QueryParseException, IllegalArgumentException {
@@ -416,8 +418,13 @@
       } catch (RuntimeException | IllegalAccessException e) {
         throw error("Error in operator " + name + ":" + value, e);
       } catch (InvocationTargetException e) {
-        if (e.getCause() instanceof QueryParseException) {
-          throw (QueryParseException) e.getCause();
+        Optional<QueryParseException> queryParseException =
+            Throwables.getCausalChain(e).stream()
+                .filter(QueryParseException.class::isInstance)
+                .map(QueryParseException.class::cast)
+                .findAny();
+        if (queryParseException.isPresent()) {
+          throw queryParseException.get();
         }
         throw error("Error in operator " + name + ":" + value, e.getCause());
       }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index ea23d91..1c8bbc3 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -79,7 +79,7 @@
   private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
   private final IndexRewriter<T> rewriter;
   private final String limitField;
-  private final IntSupplier permittedLimit;
+  private final IntSupplier userQueryLimit;
   private final CallerFinder callerFinder;
 
   // This class is not generally thread-safe, but programmer error may result in it being shared
@@ -100,14 +100,14 @@
       IndexCollection<?, T, ? extends Index<?, T>> indexes,
       IndexRewriter<T> rewriter,
       String limitField,
-      IntSupplier permittedLimit) {
+      IntSupplier userQueryLimit) {
     this.metrics = new Metrics(metricMaker);
     this.schemaDef = schemaDef;
     this.indexConfig = indexConfig;
     this.indexes = indexes;
     this.rewriter = rewriter;
     this.limitField = limitField;
-    this.permittedLimit = permittedLimit;
+    this.userQueryLimit = userQueryLimit;
     this.used = new AtomicBoolean(false);
     this.callerFinder =
         CallerFinder.builder()
@@ -230,9 +230,10 @@
         checkSupportedForQueries(q);
         int limit = getEffectiveLimit(q);
         limits.add(limit);
+        int initialPageSize = getInitialPageSize(limit);
 
-        if (limit == getBackendSupportedLimit()) {
-          limit--;
+        if (initialPageSize == getBackendSupportedLimit()) {
+          initialPageSize--;
         }
 
         int page = (start / limit) + 1;
@@ -241,12 +242,35 @@
               "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);
+        // Always bump initial page size 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.
+        try {
+          initialPageSize = Math.addExact(initialPageSize, 1);
+        } catch (ArithmeticException e) {
+          initialPageSize = Integer.MAX_VALUE;
+        }
+
+        // If pageSizeMultiplier is set to 1 (default), update it to 10 for no-limit queries as
+        // it helps improve performance and also prevents no-limit queries from severely degrading
+        // when pagination type is OFFSET.
+        int pageSizeMultiplier = indexConfig.pageSizeMultiplier();
+        if (isNoLimit && pageSizeMultiplier == 1) {
+          pageSizeMultiplier = 10;
+        }
+
+        QueryOptions opts =
+            createOptions(
+                indexConfig,
+                start,
+                initialPageSize,
+                pageSizeMultiplier,
+                limit,
+                getRequestedFields());
+        logger.atFine().log("Query options: %s", opts);
+        // Apply index-specific rewrite first
+        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
+        pred = rewriter.rewrite(pred, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
@@ -259,6 +283,9 @@
 
         @SuppressWarnings("unchecked")
         DataSource<T> s = (DataSource<T>) pred;
+        if (initialPageSize < limit && !(pred instanceof AndSource)) {
+          s = new PaginatingSource<>(s, start, indexConfig);
+        }
         sources.add(s);
       }
 
@@ -318,8 +345,14 @@
   }
 
   protected QueryOptions createOptions(
-      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
-    return QueryOptions.create(indexConfig, start, limit, requestedFields);
+      IndexConfig indexConfig,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> requestedFields) {
+    return QueryOptions.create(
+        indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
   }
 
   /**
@@ -336,7 +369,7 @@
       return requestedFields;
     }
     Index<?, T> index = indexes.getSearchIndex();
-    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.of();
+    return index != null ? index.getSchema().getStoredFields() : ImmutableSet.of();
   }
 
   /**
@@ -357,14 +390,14 @@
   }
 
   private int getPermittedLimit() {
-    return enforceVisibility ? permittedLimit.getAsInt() : Integer.MAX_VALUE;
+    return enforceVisibility ? userQueryLimit.getAsInt() : Integer.MAX_VALUE;
   }
 
   private int getBackendSupportedLimit() {
     return indexConfig.maxLimit();
   }
 
-  private int getEffectiveLimit(Predicate<T> p) {
+  public int getEffectiveLimit(Predicate<T> p) {
     if (isNoLimit == true) {
       return Integer.MAX_VALUE;
     }
@@ -383,6 +416,7 @@
     int result = Ordering.natural().min(possibleLimits);
     // Should have short-circuited from #query or thrown some other exception before getting here.
     checkState(result > 0, "effective limit should be positive");
+
     return result;
   }
 
@@ -393,5 +427,17 @@
         .findFirst();
   }
 
+  protected IntSupplier getUserQueryLimit() {
+    return userQueryLimit;
+  }
+
+  protected int getInitialPageSize(int queryLimit) {
+    return queryLimit;
+  }
+
   protected abstract String formatForLogging(T t);
+
+  protected abstract int getIndexSize();
+
+  protected abstract int getBatchSize();
 }
diff --git a/java/com/google/gerrit/index/query/RegexPredicate.java b/java/com/google/gerrit/index/query/RegexPredicate.java
index 60a2a9e..4c76770 100644
--- a/java/com/google/gerrit/index/query/RegexPredicate.java
+++ b/java/com/google/gerrit/index/query/RegexPredicate.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 public abstract class RegexPredicate<I> extends IndexPredicate<I> {
-  protected RegexPredicate(FieldDef<I, ?> def, String value) {
+  protected RegexPredicate(SchemaField<I, ?> def, String value) {
     super(def, value);
   }
 
-  protected RegexPredicate(FieldDef<I, ?> def, String name, String value) {
+  protected RegexPredicate(SchemaField<I, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/index/query/ResultSet.java b/java/com/google/gerrit/index/query/ResultSet.java
index 65fcd45..b4bd19e 100644
--- a/java/com/google/gerrit/index/query/ResultSet.java
+++ b/java/com/google/gerrit/index/query/ResultSet.java
@@ -49,4 +49,6 @@
    * the iterator has finished.
    */
   void close();
+
+  Object searchAfter();
 }
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 42f8aa8..1fd81a6 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -14,23 +14,23 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.json.JavaSqlTimestampHelper;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 // TODO: Migrate this to IntegerRangePredicate
 public abstract class TimestampRangePredicate<I> extends IndexPredicate<I> {
-  protected static Timestamp parse(String value) throws QueryParseException {
+  protected static Instant parse(String value) throws QueryParseException {
     try {
-      return JavaSqlTimestampHelper.parseTimestamp(value);
+      return JavaSqlTimestampHelper.parseTimestamp(value).toInstant();
     } catch (IllegalArgumentException e) {
       // parseTimestamp's errors are specific and helpful, so preserve them.
       throw new QueryParseException(e.getMessage(), e);
     }
   }
 
-  protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
+  protected TimestampRangePredicate(SchemaField<I, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
@@ -38,7 +38,7 @@
     return (Timestamp) this.getField().get(object);
   }
 
-  public abstract Date getMinTimestamp();
+  public abstract Instant getMinTimestamp();
 
-  public abstract Date getMaxTimestamp();
+  public abstract Instant getMaxTimestamp();
 }
diff --git a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
index b0a394e..9158a39 100644
--- a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
+++ b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
@@ -14,16 +14,24 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.common.UsedAt;
+
 public class TooManyTermsInQueryException extends QueryParseException {
   private static final long serialVersionUID = 1L;
 
   private static final String MESSAGE = "too many terms in query";
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException() {
     super(MESSAGE);
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException(Throwable why) {
     super(MESSAGE, why);
   }
+
+  public TooManyTermsInQueryException(int numTerms, int maxConfiguredTerms) {
+    super(MESSAGE + String.format(": %d terms (max = %d)", numTerms, maxConfiguredTerms));
+  }
 }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index b727e96..92bc126 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -19,15 +19,17 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -47,11 +49,14 @@
 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.time.Instant;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -69,12 +74,14 @@
 
   private final String indexName;
   private final Map<K, D> indexedDocuments;
+  private int queryCount;
 
   AbstractFakeIndex(Schema<V> schema, SitePaths sitePaths, String indexName) {
     this.schema = schema;
     this.sitePaths = sitePaths;
     this.indexName = indexName;
     this.indexedDocuments = new HashMap<>();
+    this.queryCount = 0;
   }
 
   @Override
@@ -108,20 +115,35 @@
     }
   }
 
+  public int getQueryCount() {
+    return queryCount;
+  }
+
   @Override
   public DataSource<V> getSource(Predicate<V> p, QueryOptions opts) {
     List<V> results;
     synchronized (indexedDocuments) {
-      results =
+      Stream<V> valueStream =
           indexedDocuments.values().stream()
               .map(doc -> valueFor(doc))
               .filter(doc -> p.asMatchable().match(doc))
-              .sorted(sortingComparator())
-              .skip(opts.start())
-              .limit(opts.limit())
-              .collect(toImmutableList());
+              .sorted(sortingComparator());
+      if (opts.searchAfter() != null) {
+        ImmutableList<V> valueList = valueStream.collect(toImmutableList());
+        int fromIndex =
+            IntStream.range(0, valueList.size())
+                    .filter(i -> keyFor(valueList.get(i)).equals(opts.searchAfter()))
+                    .findFirst()
+                    .orElse(-1)
+                + 1;
+        int toIndex = Math.min(fromIndex + opts.pageSize(), valueList.size());
+        results = valueList.subList(fromIndex, toIndex);
+      } else {
+        results = valueStream.skip(opts.start()).limit(opts.pageSize()).collect(toImmutableList());
+      }
+      queryCount++;
     }
-    return new DataSource<V>() {
+    return new DataSource<>() {
       @Override
       public int getCardinality() {
         return results.size();
@@ -129,15 +151,23 @@
 
       @Override
       public ResultSet<V> read() {
-        return new ListResultSet<>(results);
+        return new ListResultSet<>(results) {
+          @Nullable
+          @Override
+          public Object searchAfter() {
+            @Nullable V last = Iterables.getLast(results, null);
+            return last != null ? keyFor(last) : null;
+          }
+        };
       }
 
       @Override
       public ResultSet<FieldBundle> readRaw() {
         ImmutableList.Builder<FieldBundle> fieldBundles = ImmutableList.builder();
+        K searchAfter = null;
         for (V result : results) {
           ImmutableListMultimap.Builder<String, Object> fields = ImmutableListMultimap.builder();
-          for (FieldDef<V, ?> field : getSchema().getFields().values()) {
+          for (SchemaField<V, ?> field : getSchema().getSchemaFields().values()) {
             if (field.get(result) == null) {
               continue;
             }
@@ -147,9 +177,17 @@
               fields.put(field.getName(), field.get(result));
             }
           }
-          fieldBundles.add(new FieldBundle(fields.build()));
+          fieldBundles.add(new FieldBundle(fields.build(), /* storesIndexedFields= */ false));
+          searchAfter = keyFor(result);
         }
-        return new ListResultSet<>(fieldBundles.build());
+        ImmutableList<FieldBundle> resultSet = fieldBundles.build();
+        K finalSearchAfter = searchAfter;
+        return new ListResultSet<>(resultSet) {
+          @Override
+          public Object searchAfter() {
+            return finalSearchAfter;
+          }
+        };
       }
     };
   }
@@ -205,7 +243,7 @@
       Comparator<ChangeData> lastUpdated =
           Comparator.comparing(cd -> cd.change().getLastUpdatedOn());
       Comparator<ChangeData> merged =
-          Comparator.comparing(cd -> cd.getMergedOn().orElse(new Timestamp(0)));
+          Comparator.comparing(cd -> cd.getMergedOn().orElse(Instant.EPOCH));
       Comparator<ChangeData> id = Comparator.comparing(cd -> cd.getId().get());
       return lastUpdated.thenComparing(merged).thenComparing(id).reversed();
     }
@@ -213,8 +251,8 @@
     @Override
     protected Map<String, Object> docFor(ChangeData value) {
       ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
-        if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
+        if (ChangeField.MERGEABLE_SPEC.getName().equals(field.getName()) && skipMergable) {
           continue;
         }
         Object docifiedValue = field.get(value);
@@ -229,13 +267,23 @@
     protected ChangeData valueFor(Map<String, Object> doc) {
       ChangeData cd =
           changeDataFactory.create(
-              Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
-              Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
-        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
+              Project.nameKey((String) doc.get(ChangeField.PROJECT_SPEC.getName())),
+              Change.id(
+                  Integer.valueOf((String) doc.get(ChangeField.NUMERIC_ID_STR_SPEC.getName()))));
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
+        boolean isProtoField = SchemaFieldDefs.isProtoField(field);
+        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName()), isProtoField));
       }
       return cd;
     }
+
+    @Override
+    public void insert(ChangeData obj) {}
+
+    @Override
+    public void deleteByValue(ChangeData value) {
+      delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
@@ -265,6 +313,14 @@
     protected Comparator<AccountState> sortingComparator() {
       return Comparator.comparing(a -> a.account().id().get());
     }
+
+    @Override
+    public void insert(AccountState obj) {}
+
+    @Override
+    public void deleteByValue(AccountState value) {
+      delete(AccountIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link GroupIndex} where all filtering happens in-memory. */
@@ -295,6 +351,14 @@
     protected Comparator<InternalGroup> sortingComparator() {
       return Comparator.comparing(g -> g.getId().get());
     }
+
+    @Override
+    public void insert(InternalGroup obj) {}
+
+    @Override
+    public void deleteByValue(InternalGroup value) {
+      delete(GroupIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link ProjectIndex} where all filtering happens in-memory. */
@@ -324,5 +388,13 @@
     protected Comparator<ProjectData> sortingComparator() {
       return Comparator.comparing(p -> p.getProject().getName());
     }
+
+    @Override
+    public void insert(ProjectData obj) {}
+
+    @Override
+    public void deleteByValue(ProjectData value) {
+      delete(ProjectIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 }
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
index a30eaca..44bf70d 100644
--- a/java/com/google/gerrit/index/testing/BUILD
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -10,18 +10,15 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
-        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/index/testing/FakeStoredValue.java b/java/com/google/gerrit/index/testing/FakeStoredValue.java
index ca46ed1..133e0ab 100644
--- a/java/com/google/gerrit/index/testing/FakeStoredValue.java
+++ b/java/com/google/gerrit/index/testing/FakeStoredValue.java
@@ -14,15 +14,29 @@
 
 package com.google.gerrit.index.testing;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /** Bridge to recover fields from the fake index. */
 public class FakeStoredValue implements StoredValue {
   private final Object field;
+  /**
+   * Some index implementations store protos, some convert them to bytes first.
+   *
+   * <p>This property is true if field is stored in proto format.
+   */
+  private final boolean isProto;
 
-  FakeStoredValue(Object field) {
+  public FakeStoredValue(Object field) {
     this.field = field;
+    this.isProto = false;
+  }
+
+  public FakeStoredValue(Object field, boolean isProto) {
+    this.field = field;
+    this.isProto = isProto;
   }
 
   @Override
@@ -69,6 +83,25 @@
   }
 
   @Override
+  @Nullable
+  public MessageLite asProto() {
+    if (isProto) {
+      return (MessageLite) field;
+    }
+    return null;
+  }
+
+  @Override
+  @Nullable
+  @SuppressWarnings("unchecked")
+  public Iterable<MessageLite> asProtos() {
+    if (isProto) {
+      return (Iterable<MessageLite>) field;
+    }
+    return null;
+  }
+
+  @Override
   @SuppressWarnings("unchecked")
   public Iterable<byte[]> asByteArrays() {
     return (Iterable<byte[]>) field;
diff --git a/java/com/google/gerrit/index/testing/TestIndexedFields.java b/java/com/google/gerrit/index/testing/TestIndexedFields.java
new file mode 100644
index 0000000..51440fb
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/TestIndexedFields.java
@@ -0,0 +1,221 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.testing;
+
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.entities.converter.ChangeProtoConverter;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change;
+import com.google.gerrit.proto.Entities.Change_Id;
+import java.io.IOException;
+import java.sql.Timestamp;
+
+/**
+ * Collection of {@link IndexedField}, used in unit tests.
+ *
+ * <p>The list of {@link IndexedField} below are field types, that are currently supported and used
+ * in different index implementations
+ *
+ * <p>They are used in unit tests to make sure these types can be extracted to index and assigned
+ * back to object.
+ */
+public final class TestIndexedFields {
+
+  /** Test input object for {@link IndexedField} */
+  public static class TestIndexedData {
+
+    /** Key that is used to index to identify indexed object */
+    private Object key;
+
+    /** Field value that is extracted from this indexed object to the index document. */
+    private Object testFieldValue;
+
+    public Object getTestField() {
+      return testFieldValue;
+    }
+
+    public void setTestFieldValue(Object testFieldValue) {
+      this.testFieldValue = testFieldValue;
+    }
+
+    public Object getKey() {
+      return key;
+    }
+
+    public void setKey(Object key) {
+      this.key = key;
+    }
+  }
+
+  /** Setter for {@link TestIndexedData} */
+  private static class TestIndexedDataSetter<T> implements Setter<TestIndexedData, T> {
+    @Override
+    public void set(TestIndexedData testIndexedData, T value) {
+      testIndexedData.setTestFieldValue(value);
+    }
+  }
+
+  /** Getter for {@link TestIndexedData} */
+  @SuppressWarnings("unchecked")
+  private static class TestIndexedDataGetter<T> implements Getter<TestIndexedData, T> {
+    @Override
+    public T get(TestIndexedData input) throws IOException {
+      return (T) input.getTestField();
+    }
+  }
+
+  public static <T> TestIndexedDataSetter<T> setter() {
+    return new TestIndexedDataSetter<>();
+  }
+
+  public static <T> TestIndexedDataGetter<T> getter() {
+    return new TestIndexedDataGetter<>();
+  }
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_FIELD_SPEC =
+      INTEGER_FIELD.integer("integer_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>> ITERABLE_INTEGER_FIELD =
+      IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_FIELD_SPEC = ITERABLE_INTEGER_FIELD.integer("iterable_integer_test");
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_RANGE_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_RANGE_FIELD_SPEC =
+      INTEGER_RANGE_FIELD.range("integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>
+      ITERABLE_INTEGER_RANGE_FIELD =
+          IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerRangeTestField")
+              .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_RANGE_FIELD_SPEC =
+          ITERABLE_INTEGER_RANGE_FIELD.range("iterable_integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_FIELD_SPEC =
+      LONG_FIELD.longSearch("long_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_FIELD_SPEC = ITERABLE_LONG_FIELD.longSearch("iterable_long_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_RANGE_FIELD_SPEC =
+      LONG_RANGE_FIELD.range("long_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongRangeTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_RANGE_FIELD_SPEC = ITERABLE_LONG_RANGE_FIELD.range("iterable_long_range_test");
+
+  public static final IndexedField<TestIndexedData, Timestamp> TIMESTAMP_FIELD =
+      IndexedField.<TestIndexedData>timestampBuilder("TimestampTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Timestamp>.SearchSpec TIMESTAMP_FIELD_SPEC =
+      TIMESTAMP_FIELD.timestamp("timestamp_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<String>> ITERABLE_STRING_FIELD =
+      IndexedField.<TestIndexedData>iterableStringBuilder("IterableStringTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<String>>.SearchSpec
+      ITERABLE_STRING_FIELD_SPEC = ITERABLE_STRING_FIELD.fullText("iterable_test_string");
+
+  public static final IndexedField<TestIndexedData, String> STRING_FIELD =
+      IndexedField.<TestIndexedData>stringBuilder("StringTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec STRING_FIELD_SPEC =
+      STRING_FIELD.fullText("string_test");
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec PREFIX_STRING_FIELD_SPEC =
+      STRING_FIELD.prefix("prefix_string_test");
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec EXACT_STRING_FIELD_SPEC =
+      STRING_FIELD.exact("exact_string_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>> ITERABLE_STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>iterableByteArrayBuilder("IterableByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>>.SearchSpec
+      ITERABLE_STORED_BYTE_SPEC = ITERABLE_STORED_BYTE_FIELD.storedOnly("iterable_byte_test");
+
+  public static final IndexedField<TestIndexedData, byte[]> STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>byteArrayBuilder("ByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, byte[]>.SearchSpec STORED_BYTE_SPEC =
+      STORED_BYTE_FIELD.storedOnly("byte_test");
+
+  public static final IndexedField<TestIndexedData, Entities.Change> STORED_PROTO_FIELD =
+      IndexedField.<TestIndexedData, Entities.Change>builder(
+              "TestChange",
+              new TypeToken<Entities.Change>() {
+                private static final long serialVersionUID = 1L;
+              })
+          .stored()
+          .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Entities.Change>.SearchSpec
+      STORED_PROTO_FIELD_SPEC = STORED_PROTO_FIELD.storedOnly("test_change");
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>
+      ITERABLE_STORED_PROTO_FIELD =
+          IndexedField.<TestIndexedData, Iterable<Entities.Change>>builder(
+                  "IterableTestChange",
+                  new TypeToken<Iterable<Entities.Change>>() {
+                    private static final long serialVersionUID = 1L;
+                  })
+              .stored()
+              .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>.SearchSpec
+      ITERABLE_PROTO_FIELD_SPEC = ITERABLE_STORED_PROTO_FIELD.storedOnly("iterable_test_change");
+
+  public static Change createChangeProto(int id) {
+    return Entities.Change.newBuilder()
+        .setChangeId(Change_Id.newBuilder().setId(id).build())
+        .build();
+  }
+
+  private TestIndexedFields() {}
+}
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index d9cec45..7b2fe2f 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -5,6 +5,10 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
         "//lib:gson",
+        "//lib:guava",
+        "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
index 9c32aa8..b6cb5f9 100644
--- a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.json;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gson.Gson;
 import com.google.gson.JsonSyntaxException;
 import com.google.gson.TypeAdapter;
@@ -34,6 +35,7 @@
 public class EnumTypeAdapterFactory implements TypeAdapterFactory {
 
   @SuppressWarnings({"rawtypes", "unchecked"})
+  @Nullable
   @Override
   public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
     TypeAdapter<T> defaultEnumAdapter = TypeAdapters.ENUM_FACTORY.create(gson, typeToken);
diff --git a/java/com/google/gerrit/json/JavaSqlTimestampHelper.java b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
index b59cbd0d..35429f1 100644
--- a/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
+++ b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
@@ -14,11 +14,19 @@
 
 package com.google.gerrit.json;
 
+import com.google.common.base.Splitter;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.util.Calendar;
+import java.util.List;
+import java.util.TimeZone;
 
 /** Utility to parse Timestamp from a string. */
 public class JavaSqlTimestampHelper {
+
+  private static final Splitter TIMESTAMP_SPLITTER = Splitter.on(" ");
+  private static final Splitter DATE_SPLITTER = Splitter.on("-");
+  private static final Splitter TIME_SPLITTER = Splitter.on(":");
+
   /**
    * Parse a string into a timestamp.
    *
@@ -31,22 +39,22 @@
    * @return resulting timestamp.
    */
   public static Timestamp parseTimestamp(String s) {
-    String[] components = s.split(" ");
-    if (components.length < 1 || components.length > 3) {
+    List<String> components = TIMESTAMP_SPLITTER.splitToList(s);
+    if (components.size() < 1 || components.size() > 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) {
+    String date = components.get(0);
+    String time = components.size() >= 2 ? components.get(1) : null;
+    int off = components.size() == 3 ? parseTimeZone(components.get(2)) : 0;
+    List<String> dSplit = DATE_SPLITTER.splitToList(date);
+    if (dSplit.size() != 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]);
+      yy = Integer.parseInt(dSplit.get(0));
+      mm = Integer.parseInt(dSplit.get(1)) - 1;
+      dd = Integer.parseInt(dSplit.get(2));
     } catch (NumberFormatException e) {
       throw new IllegalArgumentException("Invalid date format: " + date, e);
     }
@@ -64,13 +72,13 @@
           t = time;
           f = 0;
         }
-        String[] tSplit = t.split(":");
-        if (tSplit.length != 3) {
+        List<String> tSplit = TIME_SPLITTER.splitToList(t);
+        if (tSplit.size() != 3) {
           throw new IllegalArgumentException("Invalid time format: " + time);
         }
-        hh = Integer.parseInt(tSplit[0]);
-        mi = Integer.parseInt(tSplit[1]);
-        ss = Integer.parseInt(tSplit[2]);
+        hh = Integer.parseInt(tSplit.get(0));
+        mi = Integer.parseInt(tSplit.get(1));
+        ss = Integer.parseInt(tSplit.get(2));
         ns = (int) Math.round(f * 1e9);
       } catch (NumberFormatException e) {
         throw new IllegalArgumentException("Invalid time format: " + time, e);
@@ -81,8 +89,9 @@
       ss = 0;
       ns = 0;
     }
-    @SuppressWarnings("deprecation")
-    Timestamp result = new Timestamp(Date.UTC(yy, mm, dd, hh, mi, ss) - off);
+    Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+    calendar.set(yy, mm, dd, hh, mi, ss);
+    Timestamp result = new Timestamp(calendar.toInstant().toEpochMilli() - off);
     result.setNanos(ns);
     return result;
   }
diff --git a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
new file mode 100644
index 0000000..2557515
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.gerrit.common.Nullable;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * A {@code TypeAdapterFactory} for Optional {@code SubmitRequirementExpressionResult}.
+ *
+ * <p>{@link SubmitRequirementResult#submittabilityExpressionResult} was previously serialized as a
+ * mandatory field, but was later on migrated to an optional field. The server needs to handle
+ * deserializing of both formats.
+ */
+public class OptionalSubmitRequirementExpressionResultAdapterFactory implements TypeAdapterFactory {
+
+  private static final TypeToken<?> OPTIONAL_SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(new TypeLiteral<Optional<SubmitRequirementExpressionResult>>() {}.getType());
+
+  private static final TypeToken<?> SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(SubmitRequirementExpressionResult.class);
+
+  @SuppressWarnings({"unchecked"})
+  @Nullable
+  @Override
+  public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
+    if (typeToken.equals(OPTIONAL_SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new OptionalSubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    } else if (typeToken.equals(SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new SubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    }
+    return null;
+  }
+
+  /**
+   * Reads json representation of either {@code Optional<SubmitRequirementExpressionResult>} or
+   * {@code SubmitRequirementExpressionResult}, converting it to {@code Nullable} {@code
+   * SubmitRequirementExpressionResult}.
+   */
+  @Nullable
+  private static SubmitRequirementExpressionResult readOptionalOrMandatory(
+      TypeAdapter<SubmitRequirementExpressionResult> submitRequirementExpressionResultAdapter,
+      JsonReader in) {
+    JsonElement parsed = JsonParser.parseReader(in);
+    if (parsed == null) {
+      return null;
+    }
+    // If it does not have 'value' field, then it was serialized as
+    // SubmitRequirementExpressionResult directly
+    if (parsed.getAsJsonObject().has("value")) {
+      parsed = parsed.getAsJsonObject().get("value");
+    }
+    if (parsed == null || parsed.isJsonNull() || parsed.getAsJsonObject().entrySet().isEmpty()) {
+      return null;
+    }
+    return submitRequirementExpressionResultAdapter.fromJsonTree(parsed);
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides backward compatibility for reading previously non-optional
+   * {@code SubmitRequirementExpressionResult} field.
+   */
+  private static class OptionalSubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<Optional<SubmitRequirementExpressionResult>> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public OptionalSubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public Optional<SubmitRequirementExpressionResult> read(JsonReader in) throws IOException {
+      return Optional.ofNullable(
+          readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in));
+    }
+
+    @Override
+    public void write(JsonWriter out, Optional<SubmitRequirementExpressionResult> value)
+        throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.beginObject();
+      out.name("value");
+      if (value.isPresent()) {
+        out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value.get()));
+      } else {
+        out.nullValue();
+      }
+      out.endObject();
+    }
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides forward compatibility for reading the optional {@code
+   * SubmitRequirementExpressionResult} field.
+   *
+   * <p>TODO(mariasavtchouk): Remove once updated to read the new format only.
+   */
+  private static class SubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public SubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public SubmitRequirementExpressionResult read(JsonReader in) throws IOException {
+      return readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in);
+    }
+
+    @Override
+    public void write(JsonWriter out, SubmitRequirementExpressionResult value) throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/json/OptionalTypeAdapter.java b/java/com/google/gerrit/json/OptionalTypeAdapter.java
new file mode 100644
index 0000000..9bfa72d
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalTypeAdapter.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Optional;
+
+public class OptionalTypeAdapter
+    implements JsonSerializer<Optional<?>>, JsonDeserializer<Optional<?>> {
+
+  private static final String VALUE = "value";
+
+  @Override
+  public JsonElement serialize(Optional<?> src, Type typeOfSrc, JsonSerializationContext context) {
+    Optional<?> optional = src == null ? Optional.empty() : src;
+    JsonObject json = new JsonObject();
+    json.add(VALUE, optional.map(context::serialize).orElse(JsonNull.INSTANCE));
+    return json;
+  }
+
+  @Override
+  public Optional<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.getAsJsonObject().has(VALUE)) {
+      return Optional.empty();
+    }
+
+    JsonElement value = json.getAsJsonObject().get(VALUE);
+    if (value == null || value.isJsonNull()) {
+      return Optional.empty();
+    }
+
+    // handle the situation when one uses Optional without type parameter which is an equivalent of
+    // <?> type
+    ParameterizedType parameterizedType =
+        (ParameterizedType) new TypeLiteral<Optional<?>>() {}.getType();
+    if (typeOfT instanceof ParameterizedType) {
+      parameterizedType = (ParameterizedType) typeOfT;
+      if (parameterizedType.getActualTypeArguments().length != 1) {
+        throw new JsonParseException("Expected one parameter type in Optional.");
+      }
+    }
+
+    Type optionalOf = parameterizedType.getActualTypeArguments()[0];
+    return Optional.of(context.deserialize(value, optionalOf));
+  }
+}
diff --git a/java/com/google/gerrit/json/SqlTimestampDeserializer.java b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
index e1cf382..9aeda2b 100644
--- a/java/com/google/gerrit/json/SqlTimestampDeserializer.java
+++ b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.json;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gson.JsonDeserializationContext;
 import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
@@ -30,6 +31,7 @@
 class SqlTimestampDeserializer implements JsonDeserializer<Timestamp>, JsonSerializer<Timestamp> {
   private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
 
+  @Nullable
   @Override
   public Timestamp deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
       throws JsonParseException {
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index f6c395e..6be78d9 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -41,12 +41,12 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Properties;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
@@ -266,11 +266,11 @@
       throw e;
     }
 
-    final SortedMap<String, URL> jars = new TreeMap<>();
+    final NavigableMap<String, URL> jars = new TreeMap<>();
     try (ZipFile zf = new ZipFile(path)) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
+      Iterator<? extends ZipEntry> zipEntryIt = zf.stream().iterator();
+      while (zipEntryIt.hasNext()) {
+        final ZipEntry ze = zipEntryIt.next();
         if (ze.isDirectory()) {
           continue;
         }
@@ -301,7 +301,7 @@
     move(jars, "javax.inject-1.jar", extapi);
     move(jars, "aopalliance-1.0.jar", extapi);
     move(jars, "guice-servlet-", extapi);
-    move(jars, "tomcat-servlet-api-", extapi);
+    move(jars, "servlet-api-", extapi);
 
     ClassLoader parent = ClassLoader.getSystemClassLoader();
     if (!extapi.isEmpty()) {
@@ -310,7 +310,7 @@
     return URLClassLoader.newInstance(jars.values().toArray(new URL[jars.size()]), parent);
   }
 
-  private static void extractJar(ZipFile zf, ZipEntry ze, SortedMap<String, URL> jars)
+  private static void extractJar(ZipFile zf, ZipEntry ze, NavigableMap<String, URL> jars)
       throws IOException {
     File tmp = createTempFile(safeName(ze), ".jar");
     try (OutputStream out = Files.newOutputStream(tmp.toPath());
@@ -326,8 +326,8 @@
     jars.put(name.substring(name.lastIndexOf('/')), tmp.toURI().toURL());
   }
 
-  private static void move(SortedMap<String, URL> jars, String prefix, List<URL> extapi) {
-    SortedMap<String, URL> matches = jars.tailMap(prefix);
+  private static void move(NavigableMap<String, URL> jars, String prefix, List<URL> extapi) {
+    NavigableMap<String, URL> matches = jars.tailMap(prefix, /* inclusive= */ true);
     if (!matches.isEmpty()) {
       String first = matches.firstKey();
       if (first.startsWith(prefix)) {
@@ -533,6 +533,7 @@
     return myHome;
   }
 
+  @SuppressWarnings("ReturnMissingNullable")
   private static File tmproot() {
     File tmp;
     String gerritTemp = System.getenv("GERRIT_TMP");
@@ -572,6 +573,7 @@
     }
   }
 
+  @SuppressWarnings("ReturnMissingNullable")
   private static File locateHomeDirectory() {
     // Try to find the user's home directory. If we can't find it
     // return null so the JVM's default temporary directory is used
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 5392ab4..f9dc31a 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -33,23 +33,26 @@
 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;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 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.proto.Protos;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -65,8 +68,6 @@
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
 import org.apache.lucene.document.IntPoint;
-import org.apache.lucene.document.LegacyIntField;
-import org.apache.lucene.document.LegacyLongField;
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
@@ -87,11 +88,10 @@
 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();
 
-  static String sortFieldName(FieldDef<?, ?> f) {
+  static String sortFieldName(SchemaField<?, ?> f) {
     return f.getName() + "_SORT";
   }
 
@@ -105,8 +105,11 @@
   private final ReferenceManager<IndexSearcher> searcherManager;
   private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
   private final Set<NrtFuture> notDoneNrtFutures;
+  private final AutoFlush autoFlush;
   private ScheduledExecutorService autoCommitExecutor;
+  private final Function<V, K> valueToKeyFunction;
 
+  @SuppressWarnings("ThreadPriorityCheck")
   AbstractLuceneIndex(
       Schema<V> schema,
       SitePaths sitePaths,
@@ -115,13 +118,17 @@
       ImmutableSet<String> skipFields,
       String subIndex,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
+      SearcherFactory searcherFactory,
+      AutoFlush autoFlush,
+      Function<V, K> valueToKeyFunction)
       throws IOException {
     this.schema = schema;
     this.sitePaths = sitePaths;
     this.dir = dir;
     this.name = name;
     this.skipFields = skipFields;
+    this.autoFlush = autoFlush;
+    this.valueToKeyFunction = valueToKeyFunction;
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -215,7 +222,9 @@
           }
         });
 
-    reopenThread.start();
+    if (autoFlush.equals(AutoFlush.ENABLED)) {
+      reopenThread.start();
+    }
   }
 
   @Override
@@ -293,6 +302,11 @@
   }
 
   @Override
+  public void deleteByValue(V value) {
+    delete(valueToKeyFunction.apply(value));
+  }
+
+  @Override
   public void deleteAll() {
     try {
       writer.deleteAll();
@@ -341,13 +355,9 @@
     if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
       for (Object value : values.getValues()) {
         Integer intValue = (Integer) value;
-        if (schema.useLegacyNumericFields()) {
-          doc.add(new LegacyIntField(name, intValue, store));
-        } else {
-          doc.add(new IntPoint(name, intValue));
-          if (store == Store.YES) {
-            doc.add(new StoredField(name, intValue));
-          }
+        doc.add(new IntPoint(name, intValue));
+        if (store == Store.YES) {
+          doc.add(new StoredField(name, intValue));
         }
       }
     } else if (type == FieldType.LONG) {
@@ -367,8 +377,12 @@
         doc.add(new TextField(name, (String) value, store));
       }
     } else if (type == FieldType.STORED_ONLY) {
+      boolean isProtoField = SchemaFieldDefs.isProtoField(values.getField());
       for (Object value : values.getValues()) {
-        doc.add(new StoredField(name, (byte[]) value));
+        // Lucene stores protos as bytes
+        doc.add(
+            new StoredField(
+                name, isProtoField ? Protos.toByteArray((MessageLite) value) : (byte[]) value));
       }
     } else {
       throw FieldType.badFieldType(type);
@@ -376,22 +390,17 @@
   }
 
   private void addLongField(Document doc, String name, Store store, Long longValue) {
-    if (schema.useLegacyNumericFields()) {
-      doc.add(new LegacyLongField(name, longValue, store));
-    } else {
-      doc.add(new LongPoint(name, longValue));
-      if (store == Store.YES) {
-        doc.add(new StoredField(name, longValue));
-      }
+    doc.add(new LongPoint(name, longValue));
+    if (store == Store.YES) {
+      doc.add(new StoredField(name, longValue));
     }
   }
 
   protected FieldBundle toFieldBundle(Document doc) {
-    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
     ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
     for (IndexableField field : doc.getFields()) {
-      checkArgument(allFields.containsKey(field.name()), "Unrecognized field " + field.name());
-      FieldType<?> type = allFields.get(field.name()).getType();
+      checkArgument(getSchema().hasField(field.name()), "Unrecognized field " + field.name());
+      FieldType<?> type = getSchema().getSchemaField(field.name()).getType();
       if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
         rawFields.put(field.name(), field.stringValue());
       } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
@@ -406,10 +415,10 @@
         throw FieldType.badFieldType(type);
       }
     }
-    return new FieldBundle(rawFields);
+    return new FieldBundle(rawFields, /* storesIndexedFields= */ false);
   }
 
-  private static Field.Store store(FieldDef<?, ?> f) {
+  private static Field.Store store(SchemaField<?, ?> f) {
     return f.isStored() ? Field.Store.YES : Field.Store.NO;
   }
 
@@ -484,6 +493,9 @@
     }
 
     private boolean isGenAvailableNowForCurrentSearcher() {
+      if (autoFlush.equals(AutoFlush.DISABLED)) {
+        return true;
+      }
       try {
         return reopenThread.waitForGeneration(gen, 0);
       } catch (InterruptedException e) {
@@ -526,20 +538,31 @@
 
     private <T> ResultSet<T> readImpl(Function<Document, T> mapper) {
       IndexSearcher searcher = null;
+      ScoreDoc scoreDoc = null;
       try {
         searcher = acquire();
-        int realLimit = opts.start() + opts.limit();
-        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        int realLimit = opts.start() + opts.pageSize();
+        TopFieldDocs docs =
+            opts.searchAfter() != null
+                ? searcher.searchAfter(
+                    (ScoreDoc) opts.searchAfter(), query, realLimit, sort, false, false)
+                : searcher.search(query, realLimit, sort);
         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());
+          scoreDoc = docs.scoreDocs[i];
+          Document doc = searcher.doc(scoreDoc.doc, opts.fields());
           T mapperResult = mapper.apply(doc);
           if (mapperResult != null) {
             b.add(mapperResult);
           }
         }
-        return new ListResultSet<>(b.build());
+        ScoreDoc searchAfter = scoreDoc;
+        return new ListResultSet<>(b.build()) {
+          @Override
+          public Object searchAfter() {
+            return searchAfter;
+          }
+        };
       } catch (IOException e) {
         throw new StorageException(e);
       } finally {
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 43daf25..024b102 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -15,18 +15,17 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
-import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.ID_STR_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.MERGED_ON_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
-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.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
 import java.nio.file.Path;
@@ -52,7 +52,8 @@
       Path path,
       ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
+      SearcherFactory searcherFactory,
+      AutoFlush autoFlush)
       throws IOException {
     this(
         schema,
@@ -61,7 +62,8 @@
         path.getFileName().toString(),
         skipFields,
         writerConfig,
-        searcherFactory);
+        searcherFactory,
+        autoFlush);
   }
 
   ChangeSubIndex(
@@ -71,9 +73,25 @@
       String subIndex,
       ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
+      SearcherFactory searcherFactory,
+      AutoFlush autoFlush)
       throws IOException {
-    super(schema, sitePaths, dir, NAME, skipFields, subIndex, writerConfig, searcherFactory);
+    super(
+        schema,
+        sitePaths,
+        dir,
+        NAME,
+        skipFields,
+        subIndex,
+        writerConfig,
+        searcherFactory,
+        autoFlush,
+        ChangeIndex.ENTITY_TO_KEY);
+  }
+
+  @Override
+  public void insert(ChangeData obj) {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
   @Override
@@ -101,17 +119,14 @@
   @Override
   void add(Document doc, Values<ChangeData> values) {
     // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<ChangeData, ?> f = values.getField();
-    if (f == ChangeField.LEGACY_ID) {
-      int v = (Integer) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ChangeField.LEGACY_ID_STR) {
+    SchemaField<ChangeData, ?> f = values.getField();
+    if (f == ChangeField.NUMERIC_ID_STR_SPEC) {
       String v = (String) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
-    } else if (f == ChangeField.UPDATED) {
+      doc.add(new NumericDocValuesField(ID_STR_SORT_FIELD, Integer.valueOf(v)));
+    } else if (f == ChangeField.UPDATED_SPEC) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
-    } else if (f == ChangeField.MERGED_ON) {
+    } else if (f == ChangeField.MERGED_ON_SPEC) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
     }
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 242cffd..9c0baa8 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -15,18 +15,18 @@
 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.ID_STR;
-import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
+import static com.google.gerrit.server.index.account.AccountField.FULL_NAME_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_STR_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 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.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -49,9 +50,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 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;
 
@@ -59,19 +60,20 @@
     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 final String ID2_SORT_FIELD = sortFieldName(ID_STR);
+  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME_SPEC);
+  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT_SPEC);
+  private static final String ID_SORT_FIELD = sortFieldName(ID_FIELD_SPEC);
+  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR_FIELD_SPEC);
 
   private static Term idTerm(boolean useLegacyNumericFields, AccountState as) {
     return idTerm(useLegacyNumericFields, as.account().id());
   }
 
   private static Term idTerm(boolean useLegacyNumericFields, Account.Id id) {
-    FieldDef<AccountState, ?> idField = useLegacyNumericFields ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        useLegacyNumericFields ? ID_FIELD_SPEC : ID_STR_FIELD_SPEC;
     if (useLegacyNumericFields) {
-      return QueryBuilder.intTerm(idField.getName(), id.get());
+      return QueryBuilder.intTerm(idField.getName());
     }
     return QueryBuilder.stringTerm(idField.getName(), Integer.toString(id.get()));
   }
@@ -83,7 +85,7 @@
   private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
     return FSDirectory.open(indexDir);
@@ -94,7 +96,8 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Provider<AccountCache> accountCache,
-      @Assisted Schema<AccountState> schema)
+      @Assisted Schema<AccountState> schema,
+      AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
@@ -104,7 +107,9 @@
         ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, ACCOUNTS),
-        new SearcherFactory());
+        new SearcherFactory(),
+        autoFlush,
+        AccountIndex.ENTITY_TO_KEY);
     this.accountCache = accountCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
@@ -114,17 +119,17 @@
   @Override
   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) {
+    SchemaField<AccountState, ?> f = values.getField();
+    if (f == ID_FIELD_SPEC) {
       int v = (Integer) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ID_STR) {
+    } else if (f == ID_STR_FIELD_SPEC) {
       String v = (String) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
-    } else if (f == FULL_NAME) {
+    } else if (f == FULL_NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(FULL_NAME_SORT_FIELD, new BytesRef(value)));
-    } else if (f == PREFERRED_EMAIL_EXACT) {
+    } else if (f == PREFERRED_EMAIL_EXACT_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(EMAIL_SORT_FIELD, new BytesRef(value)));
     }
@@ -134,7 +139,16 @@
   @Override
   public void replace(AccountState as) {
     try {
-      replace(idTerm(getSchema().useLegacyNumericFields(), as), toDocument(as)).get();
+      replace(idTerm(getSchema().hasField(ID_FIELD_SPEC), as), toDocument(as)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
+  public void insert(AccountState as) {
+    try {
+      insert(toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -143,7 +157,7 @@
   @Override
   public void delete(Account.Id key) {
     try {
-      delete(idTerm(getSchema().useLegacyNumericFields(), key)).get();
+      delete(idTerm(getSchema().hasField(ID_FIELD_SPEC), key)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -152,15 +166,14 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    queryBuilder.getSchema().useLegacyNumericFields();
     return new LuceneQuerySource(
-        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().useLegacyNumericFields())),
+        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().hasField(ID_FIELD_SPEC))),
         queryBuilder.toQuery(p),
         getSort());
   }
 
   private Sort getSort() {
-    String idSortField = getSchema().useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
+    String idSortField = getSchema().hasField(ID_FIELD_SPEC) ? ID_SORT_FIELD : ID2_SORT_FIELD;
     return new Sort(
         new SortField(FULL_NAME_SORT_FIELD, SortField.Type.STRING, false),
         new SortField(EMAIL_SORT_FIELD, SortField.Type.STRING, false),
@@ -169,10 +182,11 @@
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    FieldDef<AccountState, ?> idField = getSchema().useLegacyNumericFields() ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        getSchema().hasField(ID_FIELD_SPEC) ? ID_STR_FIELD_SPEC : ID_STR_FIELD_SPEC;
     Account.Id id =
         Account.id(
-            getSchema().useLegacyNumericFields()
+            getSchema().hasField(ID_FIELD_SPEC)
                 ? doc.getField(idField.getName()).numericValue().intValue()
                 : Integer.valueOf(doc.getField(idField.getName()).stringValue()));
     // Use the AccountCache rather than depending on any stored fields in the document (of which
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index ac616ca..6365260 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,9 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 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.LEGACY_ID_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 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;
@@ -34,15 +33,18 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
@@ -55,6 +57,7 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.inject.Inject;
@@ -63,8 +66,11 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -83,7 +89,7 @@
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.search.TopDocs;
 import org.apache.lucene.search.TopFieldDocs;
-import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -97,32 +103,21 @@
 public class LuceneChangeIndex implements ChangeIndex {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
-  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
-  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
-  static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
+  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED_SPEC);
+  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON_SPEC);
+  static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.NUMERIC_ID_STR_SPEC);
 
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
-  private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
+  private static final String CHANGE_FIELD = ChangeField.CHANGE_SPEC.getName();
 
-  @FunctionalInterface
-  interface IdTerm {
-    Term get(String name, int id);
+  static Term idTerm(ChangeData cd) {
+    return idTerm(cd.getId());
   }
 
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
-    return idTerm(idTerm, idField, cd.getId());
-  }
-
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
-    return idTerm.get(idField.getName(), id.get());
-  }
-
-  @FunctionalInterface
-  interface ChangeIdExtractor {
-    Change.Id extract(IndexableField f);
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.stringTerm(NUMERIC_ID_STR_SPEC.getName(), Integer.toString(id.get()));
   }
 
   private final ListeningExecutorService executor;
@@ -131,12 +126,6 @@
   private final QueryBuilder<ChangeData> queryBuilder;
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
-
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final FieldDef<ChangeData, ?> idField;
-  private final String idSortFieldName;
-  private final IdTerm idTerm;
-  private final ChangeIdExtractor extractor;
   private final ImmutableSet<String> skipFields;
 
   @Inject
@@ -145,7 +134,8 @@
       SitePaths sitePaths,
       @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
       ChangeData.Factory changeDataFactory,
-      @Assisted Schema<ChangeData> schema)
+      @Assisted Schema<ChangeData> schema,
+      AutoFlush autoFlush)
       throws IOException {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
@@ -153,7 +143,7 @@
     this.skipFields =
         MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex()
             ? ImmutableSet.of()
-            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
+            : ImmutableSet.of(ChangeField.MERGEABLE_SPEC.getName());
 
     GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
     GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
@@ -166,20 +156,22 @@
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramOpen",
               skipFields,
               openConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
       closedIndex =
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramClosed",
               skipFields,
               closedConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
     } else {
       Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
       openIndex =
@@ -189,7 +181,8 @@
               dir.resolve(CHANGES_OPEN),
               skipFields,
               openConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
       closedIndex =
           new ChangeSubIndex(
               schema,
@@ -197,22 +190,9 @@
               dir.resolve(CHANGES_CLOSED),
               skipFields,
               closedConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
     }
-
-    idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
-    idSortFieldName = schema.useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
-    idTerm =
-        (name, id) ->
-            this.schema.useLegacyNumericFields()
-                ? QueryBuilder.intTerm(name, id)
-                : QueryBuilder.stringTerm(name, Integer.toString(id));
-    extractor =
-        (f) ->
-            Change.id(
-                this.schema.useLegacyNumericFields()
-                    ? f.numericValue().intValue()
-                    : Integer.valueOf(f.stringValue()));
   }
 
   @Override
@@ -231,7 +211,7 @@
 
   @Override
   public void replace(ChangeData cd) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, 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);
@@ -247,10 +227,31 @@
   }
 
   @Override
-  public void delete(Change.Id changeId) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, changeId);
+  public void insert(ChangeData cd) {
+    // toDocument is essentially static and doesn't depend on the specific
+    // sub-index, so just pick one.
+    Document doc = openIndex.toDocument(cd);
     try {
-      Futures.allAsList(openIndex.delete(id), closedIndex.delete(id)).get();
+      if (cd.change().isNew()) {
+        openIndex.insert(doc).get();
+      } else {
+        closedIndex.insert(doc).get();
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
+  public void deleteByValue(ChangeData value) {
+    delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+  }
+
+  @Override
+  public void delete(Change.Id changeId) {
+    Term idTerm = LuceneChangeIndex.idTerm(changeId);
+    try {
+      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -287,7 +288,7 @@
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
         new SortField(MERGED_ON_SORT_FIELD, SortField.Type.LONG, true),
-        new SortField(idSortFieldName, SortField.Type.LONG, true));
+        new SortField(ID_STR_SORT_FIELD, SortField.Type.LONG, true));
   }
 
   private class QuerySource implements ChangeDataSource {
@@ -297,6 +298,7 @@
     private final QueryOptions opts;
     private final Sort sort;
     private final Function<Document, FieldBundle> rawDocumentMapper;
+    private final boolean isSearchAfterPagination;
 
     private QuerySource(
         List<ChangeSubIndex> indexes,
@@ -311,11 +313,16 @@
       this.opts = opts;
       this.sort = sort;
       this.rawDocumentMapper = rawDocumentMapper;
+      this.isSearchAfterPagination =
+          opts.config().paginationType().equals(PaginationType.SEARCH_AFTER);
     }
 
     @Override
     public int getCardinality() {
-      return 10; // TODO(dborowitz): estimate from Lucene?
+      if (predicate instanceof HasCardinality) {
+        return ((HasCardinality) predicate).getCardinality();
+      }
+      return 10;
     }
 
     @Override
@@ -335,12 +342,12 @@
         throw new StorageException("interrupted");
       }
 
-      final Set<String> fields = IndexUtils.changeFields(opts, schema.useLegacyNumericFields());
+      final Set<String> fields = IndexUtils.changeFields(opts);
       return new ChangeDataResults(
           executor.submit(
-              new Callable<List<Document>>() {
+              new Callable<Results>() {
                 @Override
-                public List<Document> call() throws IOException {
+                public Results call() throws IOException {
                   return doRead(fields);
                 }
 
@@ -355,14 +362,18 @@
     @Override
     public ResultSet<FieldBundle> readRaw() {
       List<Document> documents;
+      Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
+
       try {
-        documents = doRead(IndexUtils.changeFields(opts, schema.useLegacyNumericFields()));
+        Results r = doRead(IndexUtils.changeFields(opts));
+        documents = r.docs;
+        searchAfterBySubIndex = r.searchAfterBySubIndex;
       } catch (IOException e) {
         throw new StorageException(e);
       }
       ImmutableList<FieldBundle> fieldBundles =
           documents.stream().map(rawDocumentMapper).collect(toImmutableList());
-      return new ResultSet<FieldBundle>() {
+      return new ResultSet<>() {
         @Override
         public Iterator<FieldBundle> iterator() {
           return fieldBundles.iterator();
@@ -377,29 +388,57 @@
         public void close() {
           // Do nothing.
         }
+
+        @Override
+        public Object searchAfter() {
+          return searchAfterBySubIndex;
+        }
       };
     }
 
-    private List<Document> doRead(Set<String> fields) throws IOException {
+    private Results doRead(Set<String> fields) throws IOException {
       IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
+      Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex = new HashMap<>();
       try {
-        int realLimit = opts.start() + opts.limit();
-        if (Integer.MAX_VALUE - opts.limit() < opts.start()) {
-          realLimit = Integer.MAX_VALUE;
+        int realPageSize = opts.start() + opts.pageSize();
+        if (Integer.MAX_VALUE - opts.pageSize() < opts.start()) {
+          realPageSize = Integer.MAX_VALUE;
         }
-        TopFieldDocs[] hits = new TopFieldDocs[indexes.size()];
+        List<TopFieldDocs> hits = new ArrayList<>();
+        int searchAfterHitsCount = 0;
         for (int i = 0; i < indexes.size(); i++) {
-          searchers[i] = indexes.get(i).acquire();
-          hits[i] = searchers[i].search(query, realLimit, sort);
+          ChangeSubIndex subIndex = indexes.get(i);
+          searchers[i] = subIndex.acquire();
+          if (isSearchAfterPagination) {
+            ScoreDoc searchAfter = getSearchAfter(subIndex);
+            int maxRemainingHits = realPageSize - searchAfterHitsCount;
+            if (maxRemainingHits > 0) {
+              TopFieldDocs subIndexHits =
+                  searchers[i].searchAfter(
+                      searchAfter,
+                      query,
+                      maxRemainingHits,
+                      sort,
+                      /* doDocScores= */ false,
+                      /* doMaxScore= */ false);
+              searchAfterHitsCount += subIndexHits.scoreDocs.length;
+              hits.add(subIndexHits);
+              searchAfterBySubIndex.put(
+                  subIndex, Iterables.getLast(Arrays.asList(subIndexHits.scoreDocs), searchAfter));
+            }
+          } else {
+            hits.add(searchers[i].search(query, realPageSize, sort));
+          }
         }
-        TopDocs docs = TopDocs.merge(sort, realLimit, hits);
+        TopDocs docs =
+            TopDocs.merge(sort, realPageSize, hits.stream().toArray(TopFieldDocs[]::new));
 
         List<Document> result = new ArrayList<>(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
           result.add(searchers[sd.shardIndex].doc(sd.doc, fields));
         }
-        return result;
+        return new Results(result, searchAfterBySubIndex);
       } finally {
         for (int i = 0; i < indexes.size(); i++) {
           if (searchers[i] != null) {
@@ -412,13 +451,43 @@
         }
       }
     }
+
+    /**
+     * Returns null for the first page or when pagination type is not {@link
+     * PaginationType#SEARCH_AFTER search-after}, otherwise returns the last doc from previous
+     * search on the given change sub-index.
+     *
+     * @param subIndex change sub-index
+     * @return the score doc that can be used to page result sets
+     */
+    @Nullable
+    private ScoreDoc getSearchAfter(ChangeSubIndex subIndex) {
+      if (isSearchAfterPagination
+          && opts.searchAfter() != null
+          && opts.searchAfter() instanceof Map
+          && ((Map<?, ?>) opts.searchAfter()).get(subIndex) instanceof ScoreDoc) {
+        return (ScoreDoc) ((Map<?, ?>) opts.searchAfter()).get(subIndex);
+      }
+      return null;
+    }
+  }
+
+  private static class Results {
+    List<Document> docs;
+    Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
+
+    public Results(List<Document> docs, Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex) {
+      this.docs = docs;
+      this.searchAfterBySubIndex = searchAfterBySubIndex;
+    }
   }
 
   private class ChangeDataResults implements ResultSet<ChangeData> {
-    private final Future<List<Document>> future;
+    private final Future<Results> future;
     private final Set<String> fields;
+    private Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
 
-    ChangeDataResults(Future<List<Document>> future, Set<String> fields) {
+    ChangeDataResults(Future<Results> future, Set<String> fields) {
       this.future = future;
       this.fields = fields;
     }
@@ -431,11 +500,13 @@
     @Override
     public ImmutableList<ChangeData> toList() {
       try {
-        List<Document> docs = future.get();
+        Results r = future.get();
+        List<Document> docs = r.docs;
+        searchAfterBySubIndex = r.searchAfterBySubIndex;
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, idField.getName()));
+          result.add(toChangeData(fields(doc, fields), fields, NUMERIC_ID_STR_SPEC.getName()));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -451,6 +522,11 @@
     public void close() {
       future.cancel(false /* do not interrupt Lucene */);
     }
+
+    @Override
+    public Object searchAfter() {
+      return searchAfterBySubIndex;
+    }
   }
 
   private static ListMultimap<String, IndexableField> fields(Document doc, Set<String> fields) {
@@ -477,12 +553,13 @@
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
 
+      Change.Id id = Change.id(Integer.valueOf(f.stringValue()));
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-      IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
+      IndexableField project = doc.get(PROJECT_SPEC.getName()).iterator().next();
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
-    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+    for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
       if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
         field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
       }
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 5cad588..6301421 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.server.index.group.GroupField.UUID;
+import static com.google.gerrit.server.index.group.GroupField.UUID_FIELD_SPEC;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 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.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -33,6 +34,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -45,9 +47,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 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;
 
@@ -56,14 +58,14 @@
 
   private static final String GROUPS = "groups";
 
-  private static final String UUID_SORT_FIELD = sortFieldName(UUID);
+  private static final String UUID_SORT_FIELD = sortFieldName(UUID_FIELD_SPEC);
 
   private static Term idTerm(InternalGroup group) {
     return idTerm(group.getGroupUUID());
   }
 
   private static Term idTerm(AccountGroup.UUID uuid) {
-    return QueryBuilder.stringTerm(UUID.getName(), uuid.get());
+    return QueryBuilder.stringTerm(UUID_FIELD_SPEC.getName(), uuid.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -73,7 +75,7 @@
   private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
     return FSDirectory.open(indexDir);
@@ -84,7 +86,8 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Provider<GroupCache> groupCache,
-      @Assisted Schema<InternalGroup> schema)
+      @Assisted Schema<InternalGroup> schema,
+      AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
@@ -94,7 +97,9 @@
         ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, GROUPS),
-        new SearcherFactory());
+        new SearcherFactory(),
+        autoFlush,
+        GroupIndex.ENTITY_TO_KEY);
     this.groupCache = groupCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
@@ -104,8 +109,8 @@
   @Override
   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) {
+    SchemaField<InternalGroup, ?> f = values.getField();
+    if (f == UUID_FIELD_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(UUID_SORT_FIELD, new BytesRef(value)));
     }
@@ -122,6 +127,15 @@
   }
 
   @Override
+  public void insert(InternalGroup group) {
+    try {
+      insert(toDocument(group)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void delete(AccountGroup.UUID key) {
     try {
       delete(idTerm(key)).get();
@@ -139,9 +153,11 @@
         new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
+  @Nullable
   @Override
   protected InternalGroup fromDocument(Document doc) {
-    AccountGroup.UUID uuid = AccountGroup.uuid(doc.getField(UUID.getName()).stringValue());
+    AccountGroup.UUID uuid =
+        AccountGroup.uuid(doc.getField(UUID_FIELD_SPEC.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 302a2da..3aa9c6e 100644
--- a/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -14,39 +14,60 @@
 
 package com.google.gerrit.lucene;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import java.util.Map;
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
+@ModuleImpl(name = AbstractIndexModule.INDEX_MODULE)
 public class LuceneIndexModule extends AbstractIndexModule {
-  public static LuceneIndexModule singleVersionAllLatest(int threads, boolean slave) {
-    return new LuceneIndexModule(ImmutableMap.of(), threads, slave);
+  private final AutoFlush autoFlush;
+
+  public static LuceneIndexModule singleVersionAllLatest(
+      int threads, boolean slave, AutoFlush autoFlush) {
+    return new LuceneIndexModule(ImmutableMap.of(), threads, slave, autoFlush);
+  }
+
+  @VisibleForTesting
+  public static LuceneIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads, boolean slave) {
+    return new LuceneIndexModule(versions, threads, slave, AutoFlush.ENABLED);
   }
 
   public static LuceneIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean slave) {
-    return new LuceneIndexModule(versions, threads, slave);
+      Map<String, Integer> versions, int threads, boolean slave, AutoFlush autoFlush) {
+    return new LuceneIndexModule(versions, threads, slave, autoFlush);
   }
 
-  public static LuceneIndexModule latestVersion(boolean slave) {
-    return new LuceneIndexModule(null, 0, slave);
+  public static LuceneIndexModule latestVersion(boolean slave, AutoFlush autoFlush) {
+    return new LuceneIndexModule(null, 0, slave, autoFlush);
   }
 
   static boolean isInMemoryTest(Config cfg) {
     return cfg.getBoolean("index", "lucene", "testInmemory", false);
   }
 
-  private LuceneIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
+  private LuceneIndexModule(
+      Map<String, Integer> singleVersions, int threads, boolean slave, AutoFlush autoFlush) {
     super(singleVersions, threads, slave);
+    this.autoFlush = autoFlush;
+  }
+
+  @Override
+  protected void configure() {
+    super.configure();
+    bind(AutoFlush.class).toInstance(autoFlush);
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 2a418ca..911d91f 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.index.project.ProjectField.NAME;
+import static com.google.gerrit.index.project.ProjectField.NAME_SPEC;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 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.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -46,9 +48,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 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;
 
@@ -56,14 +58,14 @@
     implements ProjectIndex {
   private static final String PROJECTS = "projects";
 
-  private static final String NAME_SORT_FIELD = sortFieldName(NAME);
+  private static final String NAME_SORT_FIELD = sortFieldName(NAME_SPEC);
 
   private static Term idTerm(ProjectData projectState) {
     return idTerm(projectState.getProject().getNameKey());
   }
 
   private static Term idTerm(Project.NameKey nameKey) {
-    return QueryBuilder.stringTerm(NAME.getName(), nameKey.get());
+    return QueryBuilder.stringTerm(NAME_SPEC.getName(), nameKey.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -73,7 +75,7 @@
   private static Directory dir(Schema<ProjectData> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, PROJECTS, schema);
     return FSDirectory.open(indexDir);
@@ -84,7 +86,8 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Provider<ProjectCache> projectCache,
-      @Assisted Schema<ProjectData> schema)
+      @Assisted Schema<ProjectData> schema,
+      AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
@@ -94,7 +97,9 @@
         ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, PROJECTS),
-        new SearcherFactory());
+        new SearcherFactory(),
+        autoFlush,
+        ProjectIndex.ENTITY_TO_KEY);
     this.projectCache = projectCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, PROJECTS);
@@ -104,8 +109,8 @@
   @Override
   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) {
+    SchemaField<ProjectData, ?> f = values.getField();
+    if (f == NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
     }
@@ -122,6 +127,15 @@
   }
 
   @Override
+  public void insert(ProjectData projectState) {
+    try {
+      insert(toDocument(projectState)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void delete(Project.NameKey nameKey) {
     try {
       delete(idTerm(nameKey)).get();
@@ -139,9 +153,10 @@
         new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
+  @Nullable
   @Override
   protected ProjectData fromDocument(Document doc) {
-    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
+    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME_SPEC.getName()).stringValue());
     return projectCache.get().get(nameKey).map(ProjectState::toProjectData).orElse(null);
   }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
index efe489b..58ae3e0 100644
--- a/java/com/google/gerrit/lucene/LuceneStoredValue.java
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -18,7 +18,9 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.util.List;
 import org.apache.lucene.index.IndexableField;
@@ -36,6 +38,7 @@
     this.field = field;
   }
 
+  @Nullable
   @Override
   public String asString() {
     return Iterables.getFirst(asStrings(), null);
@@ -46,6 +49,7 @@
     return field.stream().map(f -> f.stringValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Integer asInteger() {
     return Iterables.getFirst(asIntegers(), null);
@@ -56,6 +60,7 @@
     return field.stream().map(f -> f.numericValue().intValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Long asLong() {
     return Iterables.getFirst(asLongs(), null);
@@ -66,11 +71,13 @@
     return field.stream().map(f -> f.numericValue().longValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Timestamp asTimestamp() {
     return asLong() == null ? null : new Timestamp(asLong());
   }
 
+  @Nullable
   @Override
   public byte[] asByteArray() {
     return Iterables.getFirst(asByteArrays(), null);
@@ -81,6 +88,20 @@
     return copyAsBytes(field);
   }
 
+  @Override
+  @Nullable
+  public MessageLite asProto() {
+    // Lucene does not store protos
+    return null;
+  }
+
+  @Override
+  @Nullable
+  public Iterable<MessageLite> asProtos() {
+    // Lucene does not store protos
+    return null;
+  }
+
   private static List<byte[]> copyAsBytes(List<IndexableField> fields) {
     return fields.stream()
         .map(
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 7d82bf5..14ad528 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.lucene;
 
 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 static org.apache.lucene.search.BooleanClause.Occur.MUST;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
@@ -33,43 +34,24 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.RegexPredicate;
 import com.google.gerrit.index.query.TimestampRangePredicate;
-import java.util.Date;
 import java.util.List;
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.document.IntPoint;
 import org.apache.lucene.document.LongPoint;
 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.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.LegacyNumericUtils;
 
-@SuppressWarnings("deprecation")
 public class QueryBuilder<V> {
-  @FunctionalInterface
-  static interface IntTermQuery {
-    Query get(String name, int value);
-  }
-
-  @FunctionalInterface
-  static interface IntRangeQuery {
-    Query get(String name, int min, int max);
-  }
-
-  @FunctionalInterface
-  static interface LongRangeQuery {
-    Query get(String name, long min, long max);
-  }
-
-  static Term intTerm(String name, int value) {
-    BytesRefBuilder builder = new BytesRefBuilder();
-    LegacyNumericUtils.intToPrefixCoded(value, 0, builder);
-    return new Term(name, builder.get());
+  /** @param name field name qparam i key value */
+  static Term intTerm(String name) {
+    checkState(false, "Lucene index implementation removed legacy numeric type");
+    return null;
   }
 
   static Term stringTerm(String name, String value) {
@@ -85,29 +67,9 @@
   private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final IntTermQuery intTermQuery;
-  private final IntRangeQuery intRangeTermQuery;
-  private final LongRangeQuery longRangeQuery;
-
   public QueryBuilder(Schema<V> schema, Analyzer analyzer) {
     this.schema = schema;
     queryBuilder = new org.apache.lucene.util.QueryBuilder(analyzer);
-    intTermQuery =
-        (name, value) ->
-            this.schema.useLegacyNumericFields()
-                ? new TermQuery(intTerm(name, value))
-                : intPoint(name, value);
-    intRangeTermQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newIntRange(name, min, max, true, true)
-                : IntPoint.newRangeQuery(name, min, max);
-    longRangeQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newLongRange(name, min, max, true, true)
-                : LongPoint.newRangeQuery(name, min, max);
   }
 
   public Query toQuery(Predicate<V> p) throws QueryParseException {
@@ -210,7 +172,7 @@
     } catch (NumberFormatException e) {
       throw new QueryParseException("not an integer: " + p.getValue(), e);
     }
-    return intTermQuery.get(p.getField().getName(), value);
+    return intPoint(p.getField().getName(), value);
   }
 
   private Query intRangeQuery(IndexPredicate<V> p) throws QueryParseException {
@@ -221,9 +183,9 @@
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
-        return intTermQuery.get(name, minimum);
+        return intPoint(name, minimum);
       }
-      return intRangeTermQuery.get(name, minimum, maximum);
+      return IntPoint.newRangeQuery(name, minimum, maximum);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
@@ -231,16 +193,18 @@
   private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
-      return longRangeQuery.get(
-          r.getField().getName(), r.getMinTimestamp().getTime(), r.getMaxTimestamp().getTime());
+      return LongPoint.newRangeQuery(
+          r.getField().getName(),
+          r.getMinTimestamp().toEpochMilli(),
+          r.getMaxTimestamp().toEpochMilli());
     }
     throw new QueryParseException("not a timestamp: " + p);
   }
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
-    if (r.getMinTimestamp().getTime() == 0) {
-      return longRangeQuery.get(
-          r.getField().getName(), r.getMaxTimestamp().getTime(), Long.MAX_VALUE);
+    if (r.getMinTimestamp().toEpochMilli() == 0) {
+      return LongPoint.newRangeQuery(
+          r.getField().getName(), r.getMaxTimestamp().toEpochMilli(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -279,10 +243,6 @@
     return query;
   }
 
-  public int toIndexTimeInMinutes(Date ts) {
-    return (int) (ts.getTime() / 60000);
-  }
-
   public Schema<V> getSchema() {
     return schema;
   }
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index c164b29..56cb220 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -17,6 +17,7 @@
  * limitations under the License.
  */
 
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import org.apache.lucene.index.DirectoryReader;
 import org.apache.lucene.index.FilterDirectoryReader;
@@ -132,6 +133,7 @@
     reference.getIndexReader().decRef();
   }
 
+  @Nullable
   @Override
   protected IndexSearcher refreshIfNeeded(IndexSearcher referenceToRefresh) throws IOException {
     final IndexReader r = referenceToRefresh.getIndexReader();
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
index 59d8227..0fe6c43 100644
--- a/java/com/google/gerrit/mail/BUILD
+++ b/java/com/google/gerrit/mail/BUILD
@@ -12,7 +12,6 @@
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/jsoup",
-        "//lib/log:log4j",
         "//lib/mime4j:core",
         "//lib/mime4j:dom",
     ],
diff --git a/java/com/google/gerrit/mail/ParserUtil.java b/java/com/google/gerrit/mail/ParserUtil.java
index 4b292f3..40c5a95 100644
--- a/java/com/google/gerrit/mail/ParserUtil.java
+++ b/java/com/google/gerrit/mail/ParserUtil.java
@@ -115,7 +115,8 @@
     int numConsecutiveDigits = 0;
     int maxConsecutiveDigits = 0;
     int numDigitGroups = 0;
-    for (char c : s.toCharArray()) {
+    for (int i = 0; i < s.length(); i++) {
+      char c = s.charAt(i);
       if (c >= '0' && c <= '9') {
         numConsecutiveDigits++;
       } else if (numConsecutiveDigits > 0) {
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 213cc3f..929e9f9 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -25,6 +25,7 @@
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.time.Instant;
 import org.apache.james.mime4j.MimeException;
 import org.apache.james.mime4j.dom.Entity;
 import org.apache.james.mime4j.dom.Message;
@@ -66,7 +67,9 @@
       messageBuilder.subject(mimeMessage.getSubject());
     }
     if (mimeMessage.getDate() != null) {
-      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
+      @SuppressWarnings("JdkObsolete")
+      Instant mimeMessageInstant = mimeMessage.getDate().toInstant();
+      messageBuilder.dateReceived(mimeMessageInstant);
     }
 
     // Add From, To and Cc
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 0cb0275..0f80a0c 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -5,6 +5,7 @@
     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/lifecycle",
@@ -14,6 +15,7 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index 1fb8c57..234378b 100644
--- a/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -33,7 +33,7 @@
 
   @Override
   public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
-    return new Counter1<F1>() {
+    return new Counter1<>() {
       @Override
       public void incrementBy(F1 field1, long value) {}
 
@@ -45,7 +45,7 @@
   @Override
   public <F1, F2> Counter2<F1, F2> newCounter(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Counter2<F1, F2>() {
+    return new Counter2<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, long value) {}
 
@@ -57,7 +57,7 @@
   @Override
   public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Counter3<F1, F2, F3>() {
+    return new Counter3<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {}
 
@@ -79,7 +79,7 @@
 
   @Override
   public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
-    return new Timer1<F1>(name, field1) {
+    return new Timer1<>(name, field1) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {}
 
@@ -91,7 +91,7 @@
   @Override
   public <F1, F2> Timer2<F1, F2> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Timer2<F1, F2>(name, field1, field2) {
+    return new Timer2<>(name, field1, field2) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {}
 
@@ -103,7 +103,7 @@
   @Override
   public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Timer3<F1, F2, F3>(name, field1, field2, field3) {
+    return new Timer3<>(name, field1, field2, field3) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
 
@@ -125,7 +125,7 @@
 
   @Override
   public <F1> Histogram1<F1> newHistogram(String name, Description desc, Field<F1> field1) {
-    return new Histogram1<F1>() {
+    return new Histogram1<>() {
       @Override
       public void record(F1 field1, long value) {}
 
@@ -137,7 +137,7 @@
   @Override
   public <F1, F2> Histogram2<F1, F2> newHistogram(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Histogram2<F1, F2>() {
+    return new Histogram2<>() {
       @Override
       public void record(F1 field1, F2 field2, long value) {}
 
@@ -149,7 +149,7 @@
   @Override
   public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Histogram3<F1, F2, F3>() {
+    return new Histogram3<>() {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value) {}
 
@@ -161,7 +161,7 @@
   @Override
   public <V> CallbackMetric0<V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc) {
-    return new CallbackMetric0<V>() {
+    return new CallbackMetric0<>() {
       @Override
       public void set(V value) {}
 
@@ -173,7 +173,7 @@
   @Override
   public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Field<F1> field1) {
-    return new CallbackMetric1<F1, V>() {
+    return new CallbackMetric1<>() {
       @Override
       public void set(F1 field1, V value) {}
 
diff --git a/java/com/google/gerrit/metrics/MetricMaker.java b/java/com/google/gerrit/metrics/MetricMaker.java
index 42ec8a0..3f9bab1 100644
--- a/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/java/com/google/gerrit/metrics/MetricMaker.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.Set;
 
@@ -78,14 +79,16 @@
    * @param name unique name of the metric.
    * @param value only value of the metric.
    * @param desc description of the metric.
+   * @return registration handle
    */
-  public <V> void newConstantMetric(String name, V value, Description desc) {
+  @CanIgnoreReturnValue
+  public <V> RegistrationHandle newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
     @SuppressWarnings("unchecked")
     Class<V> type = (Class<V>) value.getClass();
     CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
-    newTrigger(metric, () -> metric.set(value));
+    return newTrigger(metric, () -> metric.set(value));
   }
 
   /**
@@ -107,11 +110,13 @@
    * @param valueClass type of value recorded by the metric.
    * @param desc description of the metric.
    * @param trigger function to compute the value of the metric.
+   * @return registration handle
    */
-  public <V> void newCallbackMetric(
+  @CanIgnoreReturnValue
+  public <V> RegistrationHandle newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
     CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
-    newTrigger(metric, () -> metric.set(trigger.get()));
+    return newTrigger(metric, () -> metric.set(trigger.get()));
   }
 
   /**
diff --git a/java/com/google/gerrit/metrics/MetricsReservoirConfig.java b/java/com/google/gerrit/metrics/MetricsReservoirConfig.java
new file mode 100644
index 0000000..ca4cb09
--- /dev/null
+++ b/java/com/google/gerrit/metrics/MetricsReservoirConfig.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import java.time.Duration;
+
+/** Configuration of the Metrics' reservoir type and size. */
+public interface MetricsReservoirConfig {
+
+  /** Returns the reservoir type. */
+  ReservoirType reservoirType();
+
+  /** Returns the reservoir window duration. */
+  Duration reservoirWindow();
+
+  /** Returns the number of samples that the reservoir can contain */
+  int reservoirSize();
+
+  /** Returns the alpha parameter of the ExponentiallyDecaying reservoir */
+  double reservoirAlpha();
+}
diff --git a/java/com/google/gerrit/metrics/ReservoirType.java b/java/com/google/gerrit/metrics/ReservoirType.java
new file mode 100644
index 0000000..fe89752
--- /dev/null
+++ b/java/com/google/gerrit/metrics/ReservoirType.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+/** Type of reservoir for collecting metrics into. */
+public enum ReservoirType {
+  ExponentiallyDecaying,
+  SlidingTimeWindowArray,
+  SlidingTimeWindow,
+  SlidingWindow,
+  Uniform;
+}
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
index 0e554a8..92aeb4c 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -26,7 +26,7 @@
   }
 
   Counter1<F1> counter() {
-    return new Counter1<F1>() {
+    return new Counter1<>() {
       @Override
       public void incrementBy(F1 field1, long value) {
         total.incrementBy(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
index 07afc2a..e9199d9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -29,7 +29,7 @@
   }
 
   <F1, F2> Counter2<F1, F2> counter2() {
-    return new Counter2<F1, F2>() {
+    return new Counter2<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, long value) {
         total.incrementBy(value);
@@ -44,7 +44,7 @@
   }
 
   <F1, F2, F3> Counter3<F1, F2, F3> counter3() {
-    return new Counter3<F1, F2, F3>() {
+    return new Counter3<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
         total.incrementBy(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index fcba0ee..32be18d 100644
--- a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -18,8 +18,10 @@
 import static com.google.gerrit.metrics.dropwizard.MetricResource.METRIC_KIND;
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
 
+import com.codahale.metrics.Histogram;
 import com.codahale.metrics.Metric;
 import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -41,6 +43,7 @@
 import com.google.gerrit.metrics.Histogram2;
 import com.google.gerrit.metrics.Histogram3;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.metrics.Timer2;
@@ -48,10 +51,12 @@
 import com.google.gerrit.metrics.proc.JGitMetricModule;
 import com.google.gerrit.metrics.proc.ProcMetricModule;
 import com.google.gerrit.server.cache.CacheMetrics;
+import com.google.gerrit.server.config.MetricsReservoirConfigImpl;
 import com.google.inject.Inject;
 import com.google.inject.Scopes;
 import com.google.inject.Singleton;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
@@ -65,8 +70,25 @@
 @Singleton
 public class DropWizardMetricMaker extends MetricMaker {
   public static class ApiModule extends RestApiModule {
+    private final Optional<MetricsReservoirConfig> metricsReservoirConfig;
+
+    public ApiModule(MetricsReservoirConfig metricsReservoirConfig) {
+      this.metricsReservoirConfig = Optional.of(metricsReservoirConfig);
+    }
+
+    public ApiModule() {
+      this.metricsReservoirConfig = Optional.empty();
+    }
+
     @Override
     protected void configure() {
+      if (metricsReservoirConfig.isPresent()) {
+        bind(MetricsReservoirConfig.class).toInstance(metricsReservoirConfig.get());
+      } else {
+        bind(MetricsReservoirConfig.class)
+            .to(MetricsReservoirConfigImpl.class)
+            .in(Scopes.SINGLETON);
+      }
       bind(MetricRegistry.class).in(Scopes.SINGLETON);
       bind(DropWizardMetricMaker.class).in(Scopes.SINGLETON);
       bind(MetricMaker.class).to(DropWizardMetricMaker.class);
@@ -89,12 +111,14 @@
   private final MetricRegistry registry;
   private final Map<String, BucketedMetric> bucketed;
   private final Map<String, ImmutableMap<String, String>> descriptions;
+  private final MetricsReservoirConfig reservoirConfig;
 
   @Inject
-  DropWizardMetricMaker(MetricRegistry registry) {
+  DropWizardMetricMaker(MetricRegistry registry, MetricsReservoirConfig reservoirConfig) {
     this.registry = registry;
     this.bucketed = new ConcurrentHashMap<>();
     this.descriptions = new ConcurrentHashMap<>();
+    this.reservoirConfig = reservoirConfig;
   }
 
   Iterable<String> getMetricNames() {
@@ -222,7 +246,9 @@
   }
 
   TimerImpl newTimerImpl(String name) {
-    return new TimerImpl(name, registry.timer(name));
+    return new TimerImpl(
+        name,
+        registry.timer(name, () -> new Timer(DropWizardReservoirProvider.get(reservoirConfig))));
   }
 
   @Override
@@ -271,7 +297,10 @@
   }
 
   HistogramImpl newHistogramImpl(String name) {
-    return new HistogramImpl(name, registry.histogram(name));
+    return new HistogramImpl(
+        name,
+        registry.histogram(
+            name, () -> new Histogram(DropWizardReservoirProvider.get(reservoirConfig))));
   }
 
   @Override
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProvider.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProvider.java
new file mode 100644
index 0000000..3089068
--- /dev/null
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProvider.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Reservoir;
+import com.codahale.metrics.SlidingTimeWindowArrayReservoir;
+import com.codahale.metrics.SlidingTimeWindowReservoir;
+import com.codahale.metrics.SlidingWindowReservoir;
+import com.codahale.metrics.UniformReservoir;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
+import com.google.gerrit.metrics.ReservoirType;
+import java.util.concurrent.TimeUnit;
+
+class DropWizardReservoirProvider {
+
+  private DropWizardReservoirProvider() {}
+
+  static Reservoir get(MetricsReservoirConfig config) {
+    ReservoirType reservoirType = config.reservoirType();
+    switch (reservoirType) {
+      case ExponentiallyDecaying:
+        return new ExponentiallyDecayingReservoir(config.reservoirSize(), config.reservoirAlpha());
+      case SlidingTimeWindowArray:
+        return new SlidingTimeWindowArrayReservoir(
+            config.reservoirWindow().toMillis(), TimeUnit.MILLISECONDS);
+      case SlidingTimeWindow:
+        return new SlidingTimeWindowReservoir(
+            config.reservoirWindow().toMillis(), TimeUnit.MILLISECONDS);
+      case SlidingWindow:
+        return new SlidingWindowReservoir(config.reservoirSize());
+      case Uniform:
+        return new UniformReservoir(config.reservoirSize());
+
+      default:
+        throw new IllegalArgumentException(
+            "Unsupported metrics reservoir type " + reservoirType.name());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
index 4578db1..91e36b9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -26,7 +26,7 @@
   }
 
   Histogram1<F1> histogram1() {
-    return new Histogram1<F1>() {
+    return new Histogram1<>() {
       @Override
       public void record(F1 field1, long value) {
         total.record(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
index 446590c..2caa4c5 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -29,7 +29,7 @@
   }
 
   <F1, F2> Histogram2<F1, F2> histogram2() {
-    return new Histogram2<F1, F2>() {
+    return new Histogram2<>() {
       @Override
       public void record(F1 field1, F2 field2, long value) {
         total.record(value);
@@ -44,7 +44,7 @@
   }
 
   <F1, F2, F3> Histogram3<F1, F2, F3> histogram3() {
-    return new Histogram3<F1, F2, F3>() {
+    return new Histogram3<>() {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value) {
         total.record(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 7e472c9..6b17456 100644
--- a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -26,7 +26,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import java.util.TreeMap;
 import org.kohsuke.args4j.Option;
 
@@ -55,7 +55,7 @@
       throws AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
-    SortedMap<String, MetricJson> out = new TreeMap<>();
+    NavigableMap<String, MetricJson> out = new TreeMap<>();
     List<String> prefixes = new ArrayList<>(query.size());
     for (String q : query) {
       if (q.endsWith("/")) {
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index d59a1d9..27e9377 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -23,6 +23,7 @@
 import com.codahale.metrics.Timer;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import java.util.ArrayList;
@@ -144,6 +145,7 @@
     }
   }
 
+  @Nullable
   private static Boolean toBool(ImmutableMap<String, String> atts, String key) {
     return Description.TRUE_VALUE.equals(atts.get(key)) ? true : null;
   }
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricResource.java b/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
index 226edc7..8a6db67 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
@@ -20,8 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 class MetricResource extends ConfigResource {
-  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND =
-      new TypeLiteral<RestView<MetricResource>>() {};
+  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND = new TypeLiteral<>() {};
 
   private final String name;
   private final Metric metric;
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index b7d535b..36b52e1 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -28,7 +28,7 @@
 
   @SuppressWarnings("unchecked")
   Timer1<F1> timer() {
-    return new Timer1<F1>(name, (Field<F1>) fields[0]) {
+    return new Timer1<>(name, (Field<F1>) fields[0]) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index dee800e..77ce8cd 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -31,7 +31,7 @@
 
   @SuppressWarnings("unchecked")
   <F1, F2> Timer2<F1, F2> timer2() {
-    return new Timer2<F1, F2>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
+    return new Timer2<>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {
         total.record(value, unit);
@@ -47,8 +47,7 @@
 
   @SuppressWarnings("unchecked")
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
-    return new Timer3<F1, F2, F3>(
-        name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
+    return new Timer3<>(name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
index ef0ced6..84f2320 100644
--- a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
+++ b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.metrics.proc;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.sun.management.UnixOperatingSystemMXBean;
 import java.lang.management.ManagementFactory;
 import java.lang.management.OperatingSystemMXBean;
@@ -25,6 +26,7 @@
 
   private OperatingSystemMXBeanFactory() {}
 
+  @Nullable
   static OperatingSystemMXBeanInterface create() {
     OperatingSystemMXBean sys = ManagementFactory.getOperatingSystemMXBean();
     if (sys instanceof UnixOperatingSystemMXBean) {
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index 9e43a05..f5555b5 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -35,8 +35,6 @@
 
   @Override
   public long getCurrentThreadAllocatedBytes() {
-    // TODO(ms): call getCurrentThreadAllocatedBytes as soon as this is available in the patched
-    // Java version used by bazel
-    return sys.getThreadAllocatedBytes(Thread.currentThread().getId());
+    return sys.getCurrentThreadAllocatedBytes();
   }
 }
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 387ff2d..df64bc7 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -10,7 +10,6 @@
         "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
@@ -49,12 +48,11 @@
         "//lib:servlet-api-without-neverlink",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/log:log4j",
         "//lib/prolog:cafeteria",
         "//lib/prolog:compiler",
         "//lib/prolog:runtime",
diff --git a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
index 01c76c1..2b7f23e 100644
--- a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
+++ b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
@@ -14,11 +14,7 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -26,28 +22,23 @@
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-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.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
-import com.google.inject.Provider;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 import java.io.IOException;
 import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -73,12 +64,8 @@
   private boolean isUserNameCaseInsensitive;
   private ConsoleUI ui;
 
-  @Inject private GitRepositoryManager repoManager;
-  @Inject private AllUsersName allUsersName;
-  @Inject private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
-  @Inject private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
   @Inject private ExternalIds externalIds;
-  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private ExternalIdCaseSensitivityMigrator.Factory migratorFactory;
 
   @Override
   public int run() throws Exception {
@@ -93,6 +80,9 @@
               @Override
               protected void configure() {
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+                install(
+                    new FactoryModuleBuilder()
+                        .build(ExternalIdCaseSensitivityMigrator.Factory.class));
                 factory(MetaDataUpdate.InternalFactory.class);
                 DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
 
@@ -122,23 +112,9 @@
 
     manager.start();
     try {
-      try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
-        for (ExternalId extId : todo) {
-          recomputeExternalIdNoteId(extIdNotes, extId);
-          monitor.update(1);
-        }
-        if (!dryrun) {
-          try (MetaDataUpdate metaDataUpdate =
-              metaDataUpdateServerFactory.get().create(allUsersName)) {
-            metaDataUpdate.setMessage(
-                String.format(
-                    "Migration to case %ssensitive usernames",
-                    isUserNameCaseInsensitive ? "" : "in"));
-            extIdNotes.commit(metaDataUpdate);
-          }
-        }
-      }
+      migratorFactory
+          .create(!isUserNameCaseInsensitive, dryrun)
+          .migrate(todo, () -> monitor.update(1));
     } finally {
       manager.stop();
       monitor.endTask();
@@ -155,28 +131,6 @@
     return exitCode;
   }
 
-  private void recomputeExternalIdNoteId(ExternalIdNotes extIdNotes, ExternalId extId)
-      throws DuplicateKeyException, IOException {
-    if (extId.isScheme(SCHEME_GERRIT) || extId.isScheme(SCHEME_USERNAME)) {
-      ExternalIdKeyFactory keyFactory =
-          new ExternalIdKeyFactory(
-              new ExternalIdKeyFactory.Config() {
-                @Override
-                public boolean isUserNameCaseInsensitive() {
-                  return !isUserNameCaseInsensitive;
-                }
-              });
-      ExternalId.Key updatedKey = keyFactory.create(extId.key().scheme(), extId.key().id());
-      if (!extId.key().sha1().getName().equals(updatedKey.sha1().getName())) {
-        logger.atInfo().log("Converting note name of external ID: %s", extId.key());
-        ExternalId updatedExtId =
-            externalIdFactory.create(
-                updatedKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
-        extIdNotes.replace(extId, updatedExtId);
-      }
-    }
-  }
-
   private void updateGerritConfig() throws IOException, ConfigInvalidException {
     logger.atInfo().log("Setting auth.userNameCaseInsensitive to true in gerrit.config.");
     FileBasedConfig config =
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index f3691ed..0342fe5 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -23,7 +23,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
@@ -62,8 +61,10 @@
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
 import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
 import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -91,6 +92,7 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
@@ -118,6 +120,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.ExternalIdCommandsModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
 import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand.LfsPluginAuthCommandModule;
@@ -444,6 +447,7 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
     modules.add(new PluginApiModule());
 
     modules.add(new SearchingChangeCacheImplModule(replica));
@@ -491,7 +495,7 @@
           });
     }
     modules.add(new DefaultUrlFormatterModule());
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
@@ -520,12 +524,16 @@
     modules.add(new LocalMergeSuperSetComputationModule());
     modules.add(new DefaultProjectNameLockManagerModule());
 
-    List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
+    List<Module> libModules =
+        LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE_TYPE);
+    libModules.addAll(LibModuleLoader.loadModules(cfgInjector, LibModuleType.INDEX_MODULE_TYPE));
     libModules.addAll(testSysModules);
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     modules.add(new AuthModule(authConfig));
 
+    modules.add(new ExternalIdCaseSensitivityMigrator.ExternalIdCaseSensitivityMigratorModule());
+
     return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
@@ -534,10 +542,7 @@
       return indexModule;
     }
     if (indexType.isLucene()) {
-      return LuceneIndexModule.latestVersion(replica);
-    }
-    if (indexType.isElasticsearch()) {
-      return ElasticIndexModule.latestVersion(replica);
+      return LuceneIndexModule.latestVersion(replica, AutoFlush.ENABLED);
     }
     if (indexType.isFake()) {
       // Use Reflection so that we can omit the fake index binary in production code. Test code does
@@ -578,6 +583,7 @@
     if (!replica) {
       modules.add(new IndexCommandsModule(sysInjector));
       modules.add(new SequenceCommandsModule());
+      modules.add(new ExternalIdCommandsModule());
     }
     return sysInjector.createChildInjector(modules);
   }
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index 4e62a0f..4c7b47b 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.common.PageLinks;
@@ -91,6 +92,9 @@
   @Option(name = "--skip-download", usage = "Don't download given library")
   private List<String> skippedDownloads;
 
+  @Option(name = "--reindex-threads", usage = "Number of threads to use for reindex after init")
+  private int reindexThreads = 1;
+
   @Inject Browser browser;
 
   private GerritIndexStatus indexStatus;
@@ -157,11 +161,13 @@
     modules.add(new GerritServerConfigModule());
     Guice.createInjector(modules).injectMembers(this);
     if (!ReplicaUtil.isReplica(run.flags.cfg)) {
+      List<String> indicesToReindex = new ArrayList<>();
       for (SchemaDefinitions<?> schemaDef : schemaDefs) {
         if (!indexStatus.exists(schemaDef.getName())) {
-          reindex(schemaDef);
+          indicesToReindex.add(schemaDef.getName());
         }
       }
+      reindex(indicesToReindex, run.flags.isNew);
     }
     start(run);
   }
@@ -274,17 +280,23 @@
     }
   }
 
-  private void reindex(SchemaDefinitions<?> schemaDef) throws Exception {
+  private void reindex(List<String> indices, boolean isNewSite) throws Exception {
+    if (indices.isEmpty()) {
+      return;
+    }
     List<String> reindexArgs =
-        ImmutableList.of(
-            "--site-path",
-            getSitePath().toString(),
-            "--threads",
-            Integer.toString(1),
-            "--index",
-            schemaDef.getName());
+        Lists.newArrayList(
+            "--site-path", getSitePath().toString(), "--threads", Integer.toString(reindexThreads));
+    for (String index : indices) {
+      reindexArgs.add("--index");
+      reindexArgs.add(index);
+    }
+    if (isNewSite) {
+      reindexArgs.add("--disable-cache-stats");
+    }
+
     getConsoleUI()
-        .message(String.format("Init complete, reindexing %s with:", schemaDef.getName()));
+        .message(String.format("Init complete, reindexing %s with:", String.join(",", indices)));
     getConsoleUI().message(" reindex " + reindexArgs.stream().collect(joining(" ")));
     Reindex reindexPgm = new Reindex();
     reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
diff --git a/java/com/google/gerrit/pgm/JythonShell.java b/java/com/google/gerrit/pgm/JythonShell.java
index 88f7b5d..d85bdc0 100644
--- a/java/com/google/gerrit/pgm/JythonShell.java
+++ b/java/com/google/gerrit/pgm/JythonShell.java
@@ -173,7 +173,7 @@
         logger.atSevere().log("Cannot load resource %s", p);
       }
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index f651994..b7ff1f7 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -38,7 +38,7 @@
 import java.util.Collection;
 import java.util.Locale;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
diff --git a/java/com/google/gerrit/pgm/Ls.java b/java/com/google/gerrit/pgm/Ls.java
index 4211c17..4b48148 100644
--- a/java/com/google/gerrit/pgm/Ls.java
+++ b/java/com/google/gerrit/pgm/Ls.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.util.AbstractProgram;
 import java.io.IOException;
-import java.util.Enumeration;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
@@ -26,9 +27,7 @@
   @Override
   public int run() throws IOException {
     try (ZipFile zf = new ZipFile(GerritLauncher.getDistributionArchive())) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
+      for (ZipEntry ze : entriesOf(zf)) {
         String name = ze.getName();
         boolean show = false;
         show |= name.startsWith("WEB-INF/");
@@ -49,4 +48,8 @@
     }
     return 0;
   }
+
+  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+    return zipFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 6e99007..762d988 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -17,10 +17,11 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.cache.Cache;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.IndexType;
@@ -29,16 +30,27 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.cache.CacheDisplay;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.options.AutoFlush;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.util.ReplicaUtil;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
+import com.google.inject.multibindings.OptionalBinder;
+import java.io.StringWriter;
+import java.io.Writer;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.util.ArrayList;
@@ -49,13 +61,17 @@
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.io.NullOutputStream;
 import org.kohsuke.args4j.Option;
 
 public class Reindex extends SiteProgram {
-  @Option(name = "--threads", usage = "Number of threads to use for indexing")
-  private int threads = Runtime.getRuntime().availableProcessors();
+  @Option(
+      name = "--threads",
+      usage = "Number of threads to use for indexing. Default is index.batchThreads from config.")
+  private int threads = 0;
 
   @Option(
       name = "--changes-schema-version",
@@ -71,12 +87,20 @@
   @Option(name = "--index", usage = "Only reindex specified indices")
   private List<String> indices = new ArrayList<>();
 
+  @Option(
+      name = "--disable-cache-stats",
+      usage =
+          "Disables printing the cache statistics."
+              + "Defaults to true when reindex is run from init on a new site, false otherwise")
+  private boolean disableCacheStats;
+
   private Injector dbInjector;
   private Injector sysInjector;
   private Injector cfgInjector;
   private Config globalConfig;
 
   @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
+  @Inject private DynamicMap<Cache<?, ?>> cacheMap;
 
   @Override
   public int run() throws Exception {
@@ -100,6 +124,9 @@
 
     try {
       boolean ok = list ? list() : reindex();
+      if (!disableCacheStats) {
+        printCacheStats();
+      }
       return ok ? 0 : 1;
     } catch (Exception e) {
       throw die(e.getMessage(), e);
@@ -149,13 +176,14 @@
     }
     boolean replica = ReplicaUtil.isReplica(globalConfig);
     List<Module> modules = new ArrayList<>();
+    modules.add(new WorkQueueModule());
+
     Module indexModule;
     IndexType indexType = IndexModule.getIndexType(dbInjector);
     if (indexType.isLucene()) {
-      indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
-    } else if (indexType.isElasticsearch()) {
       indexModule =
-          ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
+          LuceneIndexModule.singleVersionWithExplicitVersions(
+              versions, threads, replica, AutoFlush.DISABLED);
     } else if (indexType.isFake()) {
       // Use Reflection so that we can omit the fake index binary in production code. Test code does
       // compile the component in.
@@ -175,6 +203,16 @@
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
     modules.add(indexModule);
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            super.configure();
+            OptionalBinder.newOptionalBinder(binder(), IsFirstInsertForEntry.class)
+                .setBinding()
+                .toInstance(IsFirstInsertForEntry.YES);
+          }
+        });
     modules.add(new BatchProgramModule(dbInjector));
     modules.add(
         new FactoryModule() {
@@ -184,7 +222,9 @@
           }
         });
 
-    return dbInjector.createChildInjector(modules);
+    return dbInjector.createChildInjector(
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadReindexModules(cfgInjector, versions, threads, replica)));
   }
 
   private void overrideConfig() {
@@ -222,6 +262,22 @@
     System.out.format(
         "Index %s in version %d is %sready\n",
         def.getName(), index.getSchema().getVersion(), result.success() ? "" : "NOT ");
+
     return result.success();
   }
+
+  private void printCacheStats() {
+    try (Writer sw = new StringWriter()) {
+      sw.write("Cache Statistics at the end of reindexing\n");
+      new CacheDisplay(
+              sw,
+              StreamSupport.stream(cacheMap.spliterator(), false)
+                  .map(e -> new CacheInfo(e.getExportName(), e.get()))
+                  .collect(Collectors.toList()))
+          .displayCaches();
+      System.out.print(sw.toString());
+    } catch (Exception e) {
+      System.out.format("Error displaying the cache statistics\n" + e.getMessage());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 733c9d1..6dec2d8 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.config.SitePaths;
@@ -185,6 +186,7 @@
     }
   }
 
+  @Nullable
   private Path findJarWithSecureStore(SitePaths sitePaths, String secureStoreClass)
       throws IOException {
     List<Path> jars = SiteLibraryLoaderUtil.listJars(sitePaths.lib_dir);
@@ -196,7 +198,7 @@
           return jar;
         }
       } catch (IOException e) {
-        logger.atSevere().withCause(e).log(e.getMessage());
+        logger.atSevere().withCause(e).log("%s", e.getMessage());
       }
     }
     return null;
diff --git a/java/com/google/gerrit/pgm/WarDistribution.java b/java/com/google/gerrit/pgm/WarDistribution.java
index 257fb4e..013c850 100644
--- a/java/com/google/gerrit/pgm/WarDistribution.java
+++ b/java/com/google/gerrit/pgm/WarDistribution.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.pgm.init.InitPlugins.JAR;
 import static com.google.gerrit.pgm.init.InitPlugins.PLUGIN_DIR;
 
@@ -25,7 +26,6 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.Enumeration;
 import java.util.List;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
@@ -39,9 +39,7 @@
     File myWar = GerritLauncher.getDistributionArchive();
     if (myWar.isFile()) {
       try (ZipFile zf = new ZipFile(myWar)) {
-        Enumeration<? extends ZipEntry> e = zf.entries();
-        while (e.hasMoreElements()) {
-          ZipEntry ze = e.nextElement();
+        for (ZipEntry ze : entriesOf(zf)) {
           if (ze.isDirectory()) {
             continue;
           }
@@ -65,4 +63,8 @@
     // not yet used
     throw new UnsupportedOperationException();
   }
+
+  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+    return zipFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index 56523dd..e88bb88 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
+import static com.google.gerrit.httpd.GitOverHttpServlet.GIT_COMMAND_STATUS_HEADER;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
@@ -55,6 +57,7 @@
   protected static final String P_CPU_TOTAL = "Cpu-Total";
   protected static final String P_CPU_USER = "Cpu-User";
   protected static final String P_MEMORY = "Memory";
+  protected static final String P_COMMAND_STATUS = "Command-Status";
 
   private final AsyncAppender async;
 
@@ -117,6 +120,7 @@
     set(event, P_LATENCY, System.currentTimeMillis() - req.getTimeStamp());
     set(event, P_REFERER, req.getHeader("Referer"));
     set(event, P_USER_AGENT, req.getHeader("User-Agent"));
+    set(event, P_COMMAND_STATUS, rsp.getHeader(GIT_COMMAND_STATUS_HEADER));
 
     RequestMetricsFilter.Context ctx =
         (RequestMetricsFilter.Context) req.getAttribute(RequestMetricsFilter.METRICS_CONTEXT);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
index 5f4ec43..54c587b 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_COMMAND_STATUS;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CONTENT_LENGTH;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CPU_TOTAL;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CPU_USER;
@@ -56,6 +57,7 @@
     public String memory;
     public String referer;
     public String userAgent;
+    public String commandStatus;
 
     public HttpJsonLogEntry(LoggingEvent event) {
       this.host = getMdcString(event, P_HOST);
@@ -73,6 +75,7 @@
       this.memory = getMdcString(event, P_MEMORY);
       this.referer = getMdcString(event, P_REFERER);
       this.userAgent = getMdcString(event, P_USER_AGENT);
+      this.commandStatus = getMdcString(event, P_COMMAND_STATUS);
     }
   }
 }
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index e9e6866..ddc1b5e 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -80,6 +80,9 @@
     buf.append(' ');
     opt(buf, event, HttpLog.P_MEMORY);
 
+    buf.append(' ');
+    dq_opt(buf, event, HttpLog.P_COMMAND_STATUS);
+
     buf.append('\n');
     return buf.toString();
   }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index d9e3a6a..be5fe1a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -110,7 +110,7 @@
       if (result != Result.NEW) {
         throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
       }
-      account.setMetaId(id.name()).build();
+      account.setMetaId(id.name());
     }
     return account.build();
   }
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index 62c9526..5849711 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -9,7 +9,6 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index c083296..b59b924 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -22,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.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -33,7 +34,6 @@
 import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.gerrit.pgm.init.index.IndexManagerOnInit;
 import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
-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.server.config.GerritServerConfigModule;
@@ -125,7 +125,7 @@
         } catch (StorageException e) {
           String msg = "Couldn't upgrade schema. Expected if slave and read-only database";
           System.err.println(msg);
-          logger.atSevere().withCause(e).log(msg);
+          logger.atSevere().withCause(e).log("%s", msg);
         }
 
         init.initializer.postRun(sysInjector);
@@ -176,6 +176,7 @@
    */
   protected void afterInit(SiteRun run) throws Exception {}
 
+  @Nullable
   protected List<String> getInstallPlugins() {
     try {
       if (pluginsToInstall != null && pluginsToInstall.isEmpty()) {
@@ -305,6 +306,7 @@
     return ConsoleUI.getInstance(false);
   }
 
+  @Nullable
   private SecureStoreInitData discoverSecureStoreClass() {
     String secureStore = getSecureStoreLib();
     if (Strings.isNullOrEmpty(secureStore)) {
@@ -416,8 +418,6 @@
       IndexType indexType = IndexModule.getIndexType(dbInjector);
       if (indexType.isLucene()) {
         modules.add(new LuceneIndexModuleOnInit());
-      } else if (indexType.isElasticsearch()) {
-        modules.add(new ElasticIndexModuleOnInit());
       } else if (indexType.isFake()) {
         try {
           Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModuleOnInit");
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index e2a1f04..35892f2 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -41,17 +42,20 @@
   private final SitePaths site;
   private final AllUsersName allUsers;
   private final ExternalIdFactory externalIdFactory;
+  private final AuthConfig authConfig;
 
   @Inject
   public ExternalIdsOnInit(
       InitFlags flags,
       SitePaths site,
       AllUsersNameOnInitProvider allUsers,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactory externalIdFactory,
+      AuthConfig authConfig) {
     this.flags = flags;
     this.site = site;
     this.allUsers = new AllUsersName(allUsers.get());
     this.externalIdFactory = externalIdFactory;
+    this.authConfig = authConfig;
   }
 
   public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
@@ -60,7 +64,11 @@
     if (path != null) {
       try (Repository allUsersRepo = new FileRepository(path)) {
         ExternalIdNotes extIdNotes =
-            ExternalIdNotes.loadNoCacheUpdate(allUsers, allUsersRepo, externalIdFactory);
+            ExternalIdNotes.load(
+                allUsers,
+                allUsersRepo,
+                externalIdFactory,
+                authConfig.isUserNameCaseInsensitiveMigrationMode());
         extIdNotes.insert(extIds);
         try (MetaDataUpdate metaDataUpdate =
             new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, allUsersRepo)) {
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 2f12abb..f8fcadd 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -40,7 +40,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
@@ -165,7 +165,7 @@
     return AuditLogFormatter.createBackedBy(ImmutableSet.of(account), ImmutableSet.of(), serverId);
   }
 
-  private void commit(Repository repository, GroupConfig groupConfig, Timestamp groupCreatedOn)
+  private void commit(Repository repository, GroupConfig groupConfig, Instant groupCreatedOn)
       throws IOException {
     PersonIdent personIdent =
         new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index d6a0133..3dce974 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.InternalGroup;
@@ -120,7 +121,7 @@
 
         Account persistedAccount =
             accounts.insert(
-                Account.builder(id, TimeUtil.nowTs()).setFullName(name).setPreferredEmail(email));
+                Account.builder(id, TimeUtil.now()).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 =
@@ -182,6 +183,7 @@
     return email;
   }
 
+  @Nullable
   private AccountSshKey readSshKey(Account.Id id) throws IOException {
     String defaultPublicSshKeyFile = "";
     Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index 83d9261..a6254fd 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -39,7 +39,6 @@
   private final SitePaths site;
   private final InitFlags initFlags;
   private final Section gerrit;
-  private final Section.Factory sections;
 
   @Inject
   InitIndex(ConsoleUI ui, Section.Factory sections, SitePaths site, InitFlags initFlags) {
@@ -48,7 +47,6 @@
     this.gerrit = sections.get("gerrit", null);
     this.site = site;
     this.initFlags = initFlags;
-    this.sections = sections;
   }
 
   @Override
@@ -58,13 +56,6 @@
         new IndexType(
             index.select("Type", "type", IndexType.getDefault(), IndexType.getKnownTypes()));
 
-    if (type.isElasticsearch()) {
-      Section elasticsearch = sections.get("elasticsearch", null);
-      elasticsearch.string("Index Prefix", "prefix", "gerrit_");
-      elasticsearch.string("Server", "server", "http://localhost:9200");
-      index.string("Result window size", "maxLimit", "10000");
-    }
-
     if ((site.isNew || isEmptySite()) && type.isLucene()) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
         IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true);
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 3edc732..f862e12 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -26,7 +26,7 @@
 
 @Singleton
 public class InitLabels implements InitStep {
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  private static final String KEY_COPY_CONDITION = "copyCondition";
   private static final String KEY_LABEL = "label";
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_VALUE = "value";
@@ -62,7 +62,11 @@
           LABEL_VERIFIED,
           KEY_VALUE,
           Arrays.asList("-1 Fails", "0 No score", "+1 Verified"));
-      cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
+      cfg.setString(
+          KEY_LABEL,
+          LABEL_VERIFIED,
+          KEY_COPY_CONDITION,
+          "changekind:NO_CHANGE OR changekind:NO_CODE_CHANGE");
       allProjectsConfig.save("Configure 'Verified' label");
     }
   }
diff --git a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index a7f9c5d..16c4ce7 100644
--- a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -62,6 +63,7 @@
     return pluginsInitSteps;
   }
 
+  @Nullable
   private InitStep loadInitStep(Path jar) {
     try {
       URLClassLoader pluginLoader =
diff --git a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index dffdde7..7666076 100644
--- a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -17,6 +17,7 @@
 import com.google.errorprone.annotations.FormatMethod;
 import com.google.errorprone.annotations.FormatString;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
 import java.io.Console;
 import java.util.EnumSet;
 import java.util.Set;
@@ -179,6 +180,7 @@
 
     @Override
     @FormatMethod
+    @Nullable
     public String password(String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       for (; ; ) {
diff --git a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
index 8e69eb9..fabad49 100644
--- a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
@@ -23,7 +23,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -65,7 +65,7 @@
   }
 
   @Override
-  public SortedSet<Project.NameKey> list() {
+  public NavigableSet<Project.NameKey> list() {
     throw new UnsupportedOperationException("not implemented");
   }
 
diff --git a/java/com/google/gerrit/pgm/init/api/InitUtil.java b/java/com/google/gerrit/pgm/init/api/InitUtil.java
index d038de7..7688728 100644
--- a/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -18,6 +18,7 @@
 
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileNotFoundException;
@@ -127,6 +128,7 @@
     }
   }
 
+  @Nullable
   private static InputStream open(Class<?> sibling, String name) {
     final InputStream in = sibling.getResourceAsStream(name);
     if (in == null) {
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index b5d35f4..5cc4b5d 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -166,6 +166,7 @@
     return nv;
   }
 
+  @Nullable
   public String password(String username, String password) {
     final String ov = getSecure(password);
 
diff --git a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
index 80de1e5..d1d0729 100644
--- a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
+++ b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
@@ -50,8 +50,7 @@
 
   @Override
   protected void configure() {
-    // The AccountIndex implementations (LuceneAccountIndex and
-    // ElasticAccountIndex) need AccountCache only for reading from the index.
+    // The LuceneAccountIndex needs AccountCache only for reading from the index.
     // On init we only want to write to the index, hence we don't need the
     // account cache.
     bind(AccountCache.class).toProvider(Providers.of(null));
@@ -63,8 +62,8 @@
 
     bind(AccountIndexCollection.class);
 
-    // The GroupIndex implementations (LuceneGroupIndex and ElasticGroupIndex)
-    // need GroupCache only for reading from the index. On init we only want to
+    // The LuceneGroupIndex needs GroupCache only for reading from the index. On init we only want
+    // to
     // write to the index, hence we don't need the group cache.
     bind(GroupCache.class).toProvider(Providers.of(null));
 
diff --git a/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
deleted file mode 100644
index f086ab1..0000000
--- a/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.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.pgm.init.index.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticAccountIndex;
-import com.google.gerrit.elasticsearch.ElasticGroupIndex;
-import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.inject.AbstractModule;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
-
-public class ElasticIndexModuleOnInit extends AbstractModule {
-
-  @Override
-  protected void configure() {
-    install(
-        new FactoryModuleBuilder()
-            .implement(AccountIndex.class, ElasticAccountIndex.class)
-            .build(AccountIndex.Factory.class));
-
-    install(
-        new FactoryModuleBuilder()
-            .implement(GroupIndex.class, ElasticGroupIndex.class)
-            .build(GroupIndex.Factory.class));
-
-    install(new IndexModuleOnInit());
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
index 12a44dc..078e648 100644
--- a/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
+++ b/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.AbstractModule;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 
@@ -36,5 +37,7 @@
             .build(GroupIndex.Factory.class));
 
     install(new IndexModuleOnInit());
+
+    bind(AutoFlush.class).toInstance(AutoFlush.DISABLED);
   }
 }
diff --git a/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 0a41db5..de08116 100644
--- a/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.rules;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -182,6 +183,7 @@
     }
   }
 
+  @Nullable
   private String getMyClasspath() {
     StringBuilder cp = new StringBuilder();
     appendClasspath(cp, getClass().getClassLoader());
diff --git a/java/com/google/gerrit/pgm/util/BatchGitModule.java b/java/com/google/gerrit/pgm/util/BatchGitModule.java
index d39c2fd..fa585d3 100644
--- a/java/com/google/gerrit/pgm/util/BatchGitModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchGitModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.git.GitModule;
@@ -24,6 +25,7 @@
 public class BatchGitModule extends FactoryModule {
   @Override
   protected void configure() {
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     install(new GitModule());
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index b24fdad..5bffce7 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -28,6 +29,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -40,12 +42,12 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
-import com.google.gerrit.server.approval.ApprovalCacheImpl;
 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.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
@@ -56,18 +58,18 @@
 import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecordProvider;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.SysExecutorModule;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-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;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -81,11 +83,14 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.query.change.DistinctVotersPredicate;
+import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
@@ -93,6 +98,7 @@
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
@@ -100,6 +106,7 @@
 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. */
 public class BatchProgramModule extends FactoryModule {
@@ -133,7 +140,7 @@
     // We're just running through each change
     // once, so don't worry about cache removal.
     bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {}).toInstance(DynamicSet.emptySet());
-    bind(new TypeLiteral<DynamicMap<Cache<?, ?>>>() {}).toInstance(DynamicMap.emptyMap());
+    DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {});
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
@@ -148,9 +155,8 @@
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
     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);
+    bind(EmailNewPatchSet.Factory.class).toProvider(Providers.of(null));
+    bind(CurrentUser.class).to(InternalUser.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
@@ -176,7 +182,6 @@
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
-    modules.add(ApprovalCacheImpl.module());
     modules.add(ConflictsCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
@@ -193,6 +198,8 @@
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
+    factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
@@ -202,20 +209,31 @@
     // Submit rules
     DynamicSet.setOf(binder(), SubmitRule.class);
     factory(SubmitRuleEvaluator.Factory.class);
-    modules.add(new PrologModule());
+    modules.add(new PrologModule(getConfig()));
     modules.add(new DefaultSubmitRuleModule());
     modules.add(new IgnoreSelfApprovalRuleModule());
 
+    // Global submit requirements
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
+
+    factory(FileEditsPredicate.Factory.class);
+
     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(WorkInProgressStateChanged.class).toInstance(WorkInProgressStateChanged.DISABLED);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+    bind(AttentionSetObserver.class).toInstance(AttentionSetObserver.DISABLED);
 
     ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(parentInjector, LibModuleType.SYS_BATCH_MODULE))
+            modules,
+            LibModuleLoader.loadModules(parentInjector, LibModuleType.SYS_BATCH_MODULE_TYPE))
         .stream()
         .forEach(this::install);
   }
+
+  protected Config getConfig() {
+    return parentInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+  }
 }
diff --git a/java/com/google/gerrit/pgm/util/ProxyUtil.java b/java/com/google/gerrit/pgm/util/ProxyUtil.java
index 3eb8187..c2c1141 100644
--- a/java/com/google/gerrit/pgm/util/ProxyUtil.java
+++ b/java/com/google/gerrit/pgm/util/ProxyUtil.java
@@ -59,7 +59,7 @@
       return;
     }
 
-    final URL u = new URL((!s.contains("://")) ? "http://" + s : s);
+    final URL u = new URL(!s.contains("://") ? "http://" + s : s);
     if (!"http".equals(u.getProtocol())) {
       throw new MalformedURLException("Invalid http_proxy: " + s + ": Only http supported.");
     }
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index ed6bce6..ff0b31e 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -137,7 +137,7 @@
       return Guice.createInjector(
           PRODUCTION,
           ModuleOverloader.override(
-              modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
+              modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE_TYPE)));
     } catch (CreationException ce) {
       Message first = ce.getErrorMessages().iterator().next();
       Throwable why = first.getCause();
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 0a15fda..a5c8b77 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["common/**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/prettify/common/SparseFileContent.java b/java/com/google/gerrit/prettify/common/SparseFileContent.java
index 1249b65..f40222a 100644
--- a/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 
 /**
  * A class to store subset of a file's lines in a memory efficient way. Internally, it stores lines
@@ -134,6 +135,7 @@
       return getSize();
     }
 
+    @Nullable
     private String getLine(int idx) {
       // Most requests are sequential in nature, fetching the next
       // line from the current range, or the next range.
diff --git a/java/com/google/gerrit/server/AssigneeStatusUpdate.java b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
index 3d6242b..812aad1 100644
--- a/java/com/google/gerrit/server/AssigneeStatusUpdate.java
+++ b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
@@ -16,18 +16,18 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Change to an assignee's status. */
 @AutoValue
 public abstract class AssigneeStatusUpdate {
   public static AssigneeStatusUpdate create(
-      Timestamp ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
+      Instant ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
     return new AutoValue_AssigneeStatusUpdate(ts, updatedBy, currentAssignee);
   }
 
-  public abstract Timestamp date();
+  public abstract Instant date();
 
   public abstract Account.Id updatedBy();
 
diff --git a/java/com/google/gerrit/server/AuditEvent.java b/java/com/google/gerrit/server/AuditEvent.java
index 773a307..54bbe23 100644
--- a/java/com/google/gerrit/server/AuditEvent.java
+++ b/java/com/google/gerrit/server/AuditEvent.java
@@ -82,6 +82,12 @@
     return uuid.hashCode();
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the plugin API (used in the AuditListener
+  // extension point), hence we cannot change it without breaking plugins. Hence suppress the
+  // EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object obj) {
     if (this == obj) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7080417..2be3383 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -104,6 +104,7 @@
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
@@ -111,15 +112,16 @@
         "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
-        "//lib/commons:lang",
         "//lib/commons:lang3",
         "//lib/commons:net",
+        "//lib/commons:text",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
+        "//lib/httpcomponents:httpclient",
         "//lib/jsoup",
         "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
@@ -144,6 +146,7 @@
     visibility = ["//visibility:public"],
     deps = [
         ":server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 8366b09..81cff6e 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -145,7 +145,7 @@
     ChangeMessageInfo cmi = new ChangeMessageInfo();
     cmi.id = message.getKey().uuid();
     cmi.author = accountLoader.get(message.getAuthor());
-    cmi.date = message.getWrittenOn();
+    cmi.setDate(message.getWrittenOn());
     cmi.message = message.getMessage();
     cmi.tag = message.getTag();
     cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
index d943889..be6b4cd8 100644
--- a/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -23,17 +23,17 @@
 import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
 import com.google.gerrit.server.args4j.AccountIdHandler;
 import com.google.gerrit.server.args4j.ChangeIdHandler;
+import com.google.gerrit.server.args4j.InstantHandler;
 import com.google.gerrit.server.args4j.ObjectIdHandler;
 import com.google.gerrit.server.args4j.PatchSetIdHandler;
 import com.google.gerrit.server.args4j.ProjectHandler;
 import com.google.gerrit.server.args4j.SocketAddressHandler;
-import com.google.gerrit.server.args4j.TimestampHandler;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.OptionHandlerUtil;
 import com.google.gerrit.util.cli.OptionHandlers;
 import java.net.SocketAddress;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.spi.OptionHandler;
 
@@ -49,11 +49,11 @@
     registerOptionHandler(AccountGroup.Id.class, AccountGroupIdHandler.class);
     registerOptionHandler(AccountGroup.UUID.class, AccountGroupUUIDHandler.class);
     registerOptionHandler(Change.Id.class, ChangeIdHandler.class);
+    registerOptionHandler(Instant.class, InstantHandler.class);
     registerOptionHandler(ObjectId.class, ObjectIdHandler.class);
     registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
     registerOptionHandler(ProjectState.class, ProjectHandler.class);
     registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
-    registerOptionHandler(Timestamp.class, TimestampHandler.class);
   }
 
   private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 1d38877..285657e 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -44,12 +44,13 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -66,7 +67,7 @@
 @Singleton
 public class CommentsUtil {
   public static final Ordering<Comment> COMMENT_ORDER =
-      new Ordering<Comment>() {
+      new Ordering<>() {
         @Override
         public int compare(Comment c1, Comment c2) {
           return ComparisonChain.start()
@@ -80,7 +81,7 @@
       };
 
   public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
-      new Ordering<CommentInfo>() {
+      new Ordering<>() {
         @Override
         public int compare(CommentInfo a, CommentInfo b) {
           return ComparisonChain.start()
@@ -103,6 +104,7 @@
     return PatchSet.id(changeId, comment.key.patchSetId);
   }
 
+  @Nullable
   public static String extractMessageId(@Nullable String tag) {
     if (tag == null || !tag.startsWith("mailMessageId=")) {
       return null;
@@ -132,7 +134,7 @@
   public HumanComment newHumanComment(
       ChangeNotes changeNotes,
       CurrentUser currentUser,
-      Timestamp when,
+      Instant when,
       String path,
       PatchSet.Id psId,
       short side,
@@ -304,7 +306,7 @@
   }
 
   private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
-    return c.updated.after(cm.getWrittenOn());
+    return c.getUpdated().isAfter(cm.getWrittenOn());
   }
 
   /**
@@ -337,7 +339,7 @@
   }
 
   public void putHumanComments(
-      ChangeUpdate update, HumanComment.Status status, Iterable<HumanComment> comments) {
+      ChangeUpdate update, Comment.Status status, Iterable<HumanComment> comments) {
     for (HumanComment c : comments) {
       update.putComment(status, c);
     }
@@ -440,7 +442,7 @@
       // unignore the test in PortedCommentsIT.
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       return modifiedFiles.isEmpty()
           ? null
           : modifiedFiles.values().iterator().next().oldCommitId();
diff --git a/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
index 2b48169..4ad143b 100644
--- a/java/com/google/gerrit/server/CommonConverters.java
+++ b/java/com/google/gerrit/server/CommonConverters.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.common.GitPerson;
-import java.sql.Timestamp;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
@@ -30,7 +29,7 @@
     GitPerson result = new GitPerson();
     result.name = ident.getName();
     result.email = ident.getEmailAddress();
-    result.date = new Timestamp(ident.getWhen().getTime());
+    result.setDate(ident.getWhenAsInstant());
     result.tz = ident.getTimeZoneOffset();
     return result;
   }
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 6c76de7..60f3d4b 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -121,7 +121,7 @@
           });
 
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index db0aa70..7f9fbd2 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -225,7 +225,7 @@
     Class<?> beanClass =
         (bean instanceof BeanReceiver)
             ? ((BeanReceiver) bean).getExportedBeanReceiver()
-            : getClass();
+            : bean.getClass();
     for (String plugin : dynamicBeans.plugins()) {
       Provider<DynamicBean> provider =
           dynamicBeans.byPlugin(plugin).get(beanClass.getCanonicalName());
diff --git a/java/com/google/gerrit/server/ExceptionHookImpl.java b/java/com/google/gerrit/server/ExceptionHookImpl.java
index 3986842..781f196 100644
--- a/java/com/google/gerrit/server/ExceptionHookImpl.java
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Optional;
@@ -74,7 +74,7 @@
               + "\n"
               + CONTACT_PROJECT_OWNER_USER_MESSAGE);
     }
-    if (throwable instanceof InternalServerWithUserMessageException) {
+    if (throwable instanceof MergeUpdateException) {
       return ImmutableList.of(throwable.getMessage());
     }
     return ImmutableList.of();
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index eb3e324..eda6e09 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -49,10 +49,10 @@
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -100,30 +100,30 @@
           enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
-          null);
+          /* realUser= */ null);
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return create(null, id);
+      return create(/* remotePeer= */ null, id);
     }
 
     @VisibleForTesting
     @UsedAt(UsedAt.Project.GOOGLE)
     public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
-      return runAs(null, id, null, properties);
+      return runAs(/* remotePeer= */ null, id, /* caller= */ null, properties);
     }
 
-    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return runAs(remotePeer, id, null);
+    public IdentifiedUser create(@Nullable SocketAddress remotePeer, Account.Id id) {
+      return runAs(remotePeer, id, /* caller= */ null);
     }
 
     public IdentifiedUser runAs(
-        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+        @Nullable SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
       return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
     }
 
     private IdentifiedUser runAs(
-        SocketAddress remotePeer,
+        @Nullable SocketAddress remotePeer,
         Account.Id id,
         @Nullable CurrentUser caller,
         PropertyMap properties) {
@@ -244,7 +244,7 @@
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
     this(
@@ -270,7 +270,7 @@
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser,
       PropertyMap properties) {
@@ -427,10 +427,10 @@
   }
 
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(new Date(), TimeZone.getDefault());
+    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
   }
 
-  public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
+  public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
 
     String name = ua.fullName();
@@ -450,14 +450,19 @@
               ? constructMailAddress(ua, "unknown")
               : ua.preferredEmail();
     }
-    return new PersonIdent(name, user, when, tz);
+
+    return new PersonIdent(name, user, when, zoneId);
   }
 
   private String constructMailAddress(Account ua, String host) {
     return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
-  public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
+  public PersonIdent newCommitterIdent(PersonIdent ident) {
+    return newCommitterIdent(ident.getWhenAsInstant(), ident.getZoneId());
+  }
+
+  public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
     String name = ua.fullName();
     String email = ua.preferredEmail();
@@ -492,7 +497,7 @@
       }
     }
 
-    return new PersonIdent(name, email, when, tz);
+    return new PersonIdent(name, email, when, zoneId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
index 0a6fb9f..5c0f8e4 100644
--- a/java/com/google/gerrit/server/LibModuleLoader.java
+++ b/java/com/google/gerrit/server/LibModuleLoader.java
@@ -18,12 +18,15 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.ProvisionException;
+import java.lang.reflect.Method;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
 /** Loads configured Guice modules from {@code gerrit.installModule}. */
@@ -37,6 +40,38 @@
         .collect(toList());
   }
 
+  public static List<Module> loadReindexModules(
+      Injector parent, Map<String, Integer> versions, int threads, boolean replica) {
+    Config cfg = getConfig(parent);
+    return Arrays.stream(
+            cfg.getStringList(
+                "gerrit", null, "install" + LibModuleType.INDEX_MODULE_TYPE.getConfigKey()))
+        .map(m -> createReindexModule(m, versions, threads, replica))
+        .collect(toList());
+  }
+
+  private static Module createReindexModule(
+      String className, Map<String, Integer> versions, int threads, boolean replica) {
+    Class<Module> clazz = loadModule(className);
+    try {
+
+      Method m =
+          clazz.getMethod(
+              "singleVersionWithExplicitVersions",
+              Map.class,
+              int.class,
+              boolean.class,
+              AutoFlush.class);
+
+      Module module = (Module) m.invoke(null, versions, threads, replica, AutoFlush.DISABLED);
+      logger.atInfo().log("Installed module %s", className);
+      return module;
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Unable to load libModule for %s", className);
+      throw new IllegalStateException(e);
+    }
+  }
+
   private static Config getConfig(Injector i) {
     return i.getInstance(Key.get(Config.class, GerritServerConfig.class));
   }
@@ -52,9 +87,7 @@
     try {
       return (Class<Module>) Class.forName(className);
     } catch (ClassNotFoundException | LinkageError e) {
-      String msg = "Cannot load LibModule " + className;
-      logger.atSevere().withCause(e).log(msg);
-      throw new ProvisionException(msg, e);
+      throw new ProvisionException("Cannot load LibModule " + className, e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/LibModuleType.java b/java/com/google/gerrit/server/LibModuleType.java
index b9cb196..57206aa 100644
--- a/java/com/google/gerrit/server/LibModuleType.java
+++ b/java/com/google/gerrit/server/LibModuleType.java
@@ -18,13 +18,16 @@
 public enum LibModuleType {
 
   /** Module for the sysInjector. */
-  SYS_MODULE("Module"),
+  SYS_MODULE_TYPE("Module"),
 
   /** BatchModule for the sysInjector */
-  SYS_BATCH_MODULE("BatchModule"),
+  SYS_BATCH_MODULE_TYPE("BatchModule"),
 
   /** Module for the dbInjector. */
-  DB_MODULE("DbModule");
+  DB_MODULE_TYPE("DbModule"),
+
+  /** Module for the implementation of the indexing backend. */
+  INDEX_MODULE_TYPE("IndexModule");
 
   private final String configKey;
 
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 326ddf4..2962108 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -35,12 +35,13 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.RepoContext;
 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.Map;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
@@ -106,7 +107,7 @@
         .id(psId)
         .commitId(commit)
         .uploader(update.getAccountId())
-        .createdOn(new Timestamp(update.getWhen().getTime()))
+        .createdOn(update.getWhen())
         .groups(groups)
         .pushCertificate(Optional.ofNullable(pushCertificate))
         .description(Optional.ofNullable(description))
@@ -170,4 +171,54 @@
       return src;
     }
   }
+
+  /**
+   * Gets the commit ID for the latest patch-set of a given change.
+   *
+   * <p>This also takes into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param ctx to look for pending updates in.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed nor pending commits found for the change.
+   */
+  public static RevCommit getCurrentRevCommitIncludingPending(
+      RepoContext ctx, ChangeNotes.Factory notesFactory, Change.Id changeId) throws IOException {
+    Map<String, ObjectId> refUpdates = ctx.getRepoView().getRefs(changeId.toRefPrefix());
+    refUpdates.remove("meta");
+    if (!refUpdates.isEmpty()) {
+      Optional<PatchSet.Id> latestPendingPatchSet =
+          refUpdates.keySet().stream()
+              .map(r -> PatchSet.Id.fromRef(changeId.toRefPrefix() + r))
+              .max(PatchSet.Id::compareTo);
+      if (latestPendingPatchSet.isPresent()) {
+        return ctx.getRevWalk().parseCommit(refUpdates.get(latestPendingPatchSet.get().getId()));
+      }
+    }
+    return getCurrentCommittedRevCommit(ctx.getProject(), ctx.getRevWalk(), notesFactory, changeId);
+  }
+
+  /**
+   * Gets the commit ID for the latest committed patch-set of a given change.
+   *
+   * <p>This DOES NOT take into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param project name.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed commits found for the change.
+   */
+  public static RevCommit getCurrentCommittedRevCommit(
+      Project.NameKey project,
+      RevWalk revWalk,
+      ChangeNotes.Factory notesFactory,
+      Change.Id changeId)
+      throws IOException {
+    ChangeNotes notes = notesFactory.createChecked(project, changeId);
+    return revWalk.parseCommit(notes.getCurrentPatchSet().commitId());
+  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 4d19dd0..de5f023 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
@@ -90,7 +91,7 @@
             draftComment, psIdOfDraftComment, notes.getProjectName());
         continue;
       }
-      draftComment.writtenOn = ctx.getWhen();
+      draftComment.writtenOn = Timestamp.from(ctx.getWhen());
       draftComment.tag = tag;
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 84afe8c..830928a 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -15,15 +15,14 @@
 package com.google.gerrit.server;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -32,13 +31,12 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * A {@link BatchUpdateOp} that can be used to publish draft comments
@@ -52,15 +50,14 @@
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
-  private final List<LabelVote> labelDelta = new ArrayList<>();
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
   private final ChangeMessagesUtil changeMessagesUtil;
 
+  private ObjectId preUpdateMetaId;
   private List<HumanComment> comments = new ArrayList<>();
   private String mailMessage;
-  private IdentifiedUser user;
 
   public interface Factory {
     PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
@@ -92,7 +89,7 @@
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, UnprocessableEntityException, IOException,
           PatchListNotAvailableException, CommentsRejectedException {
-    user = ctx.getIdentifiedUser();
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
 
     // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
@@ -103,7 +100,7 @@
     //   2. Each ChangeUpdate results in 1 commit in NoteDb
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
-    publishCommentUtil.publish(ctx, changeUpdate, comments, null);
+    publishCommentUtil.publish(ctx, changeUpdate, comments, /* tag= */ null);
     return insertMessage(changeUpdate);
   }
 
@@ -114,29 +111,16 @@
     }
     ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
     PatchSet ps = psUtil.get(changeNotes, psId);
-    NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
-    if (notify.shouldNotify()) {
-      RepoView repoView;
-      try {
-        repoView = ctx.getRepoView();
-      } catch (IOException ex) {
-        throw new StorageException(
-            String.format("Repository %s not found", ctx.getProject().get()), ex);
-      }
-      email
-          .create(
-              notify,
-              changeNotes,
-              ps,
-              user,
-              mailMessage,
-              ctx.getWhen(),
-              comments,
-              null,
-              labelDelta,
-              repoView)
-          .sendAsync();
-    }
+    email
+        .create(
+            ctx,
+            ps,
+            preUpdateMetaId,
+            mailMessage,
+            comments,
+            /* patchSetComment= */ null,
+            /* labels= */ ImmutableList.of())
+        .sendAsync();
     commentAdded.fire(
         ctx.getChangeData(changeNotes),
         ps,
@@ -159,7 +143,7 @@
     }
     mailMessage =
         changeMessagesUtil.setChangeMessage(
-            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, /* tag= */ null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/RequestCleanup.java b/java/com/google/gerrit/server/RequestCleanup.java
index e07d148..1d421ed 100644
--- a/java/com/google/gerrit/server/RequestCleanup.java
+++ b/java/com/google/gerrit/server/RequestCleanup.java
@@ -16,8 +16,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.inject.servlet.RequestScoped;
+import java.util.ArrayList;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
 
 /** Registers cleanup activities to be completed when a scope ends. */
@@ -25,7 +25,7 @@
 public class RequestCleanup implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final List<Runnable> cleanup = new LinkedList<>();
+  private final List<Runnable> cleanup = new ArrayList<>();
   private boolean ran;
 
   /** Register a task to be completed after the request ends. */
diff --git a/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
index 4a317c3..c6ba7b5 100644
--- a/java/com/google/gerrit/server/ReviewerByEmailSet.java
+++ b/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
@@ -30,8 +30,7 @@
 public class ReviewerByEmailSet {
   private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
 
-  public static ReviewerByEmailSet fromTable(
-      Table<ReviewerStateInternal, Address, Timestamp> table) {
+  public static ReviewerByEmailSet fromTable(Table<ReviewerStateInternal, Address, Instant> table) {
     return new ReviewerByEmailSet(table);
   }
 
@@ -39,10 +38,10 @@
     return EMPTY;
   }
 
-  private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
+  private final ImmutableTable<ReviewerStateInternal, Address, Instant> table;
   private ImmutableSet<Address> users;
 
-  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
+  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Instant> table) {
     this.table = ImmutableTable.copyOf(table);
   }
 
@@ -58,7 +57,7 @@
     return table.row(state).keySet();
   }
 
-  public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
+  public ImmutableTable<ReviewerStateInternal, Address, Instant> asTable() {
     return table;
   }
 
diff --git a/java/com/google/gerrit/server/ReviewerSet.java b/java/com/google/gerrit/server/ReviewerSet.java
index 0f6bf29..0ff68e0 100644
--- a/java/com/google/gerrit/server/ReviewerSet.java
+++ b/java/com/google/gerrit/server/ReviewerSet.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Set of reviewers on a change.
@@ -38,7 +38,7 @@
 
   public static ReviewerSet fromApprovals(Iterable<PatchSetApproval> approvals) {
     PatchSetApproval first = null;
-    Table<ReviewerStateInternal, Account.Id, Timestamp> reviewers = HashBasedTable.create();
+    Table<ReviewerStateInternal, Account.Id, Instant> reviewers = HashBasedTable.create();
     for (PatchSetApproval psa : approvals) {
       if (first == null) {
         first = psa;
@@ -58,7 +58,7 @@
     return new ReviewerSet(reviewers);
   }
 
-  public static ReviewerSet fromTable(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+  public static ReviewerSet fromTable(Table<ReviewerStateInternal, Account.Id, Instant> table) {
     return new ReviewerSet(table);
   }
 
@@ -66,10 +66,10 @@
     return EMPTY;
   }
 
-  private final ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> table;
+  private final ImmutableTable<ReviewerStateInternal, Account.Id, Instant> table;
   private ImmutableSet<Account.Id> accounts;
 
-  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Instant> table) {
     this.table = ImmutableTable.copyOf(table);
   }
 
@@ -85,7 +85,7 @@
     return table.row(state).keySet();
   }
 
-  public ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> asTable() {
+  public ImmutableTable<ReviewerStateInternal, Account.Id, Instant> asTable() {
     return table;
   }
 
diff --git a/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
index 938d985..1e0aa43 100644
--- a/java/com/google/gerrit/server/ReviewerStatusUpdate.java
+++ b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -17,17 +17,17 @@
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Change to a reviewer's status. */
 @AutoValue
 public abstract class ReviewerStatusUpdate {
   public static ReviewerStatusUpdate create(
-      Timestamp ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
+      Instant ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
     return new AutoValue_ReviewerStatusUpdate(ts, updatedBy, reviewer, state);
   }
 
-  public abstract Timestamp date();
+  public abstract Instant date();
 
   public abstract Account.Id updatedBy();
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 7c61c92..8f413f9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -23,7 +23,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
@@ -32,32 +31,25 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
-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.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
+import java.util.Collections;
+import java.util.NavigableSet;
+import java.util.Objects;
 import java.util.Set;
-import java.util.SortedSet;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
@@ -82,6 +74,7 @@
   public abstract static class StarField {
     private static final String SEPARATOR = ":";
 
+    @Nullable
     public static StarField parse(String s) {
       int p = s.indexOf(SEPARATOR);
       if (p >= 0) {
@@ -110,10 +103,15 @@
     }
   }
 
+  public enum Operation {
+    ADD,
+    REMOVE
+  }
+
   @AutoValue
   public abstract static class StarRef {
     private static final StarRef MISSING =
-        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
+        new AutoValue_StarredChangesUtil_StarRef(null, Collections.emptyNavigableSet());
 
     private static StarRef create(Ref ref, Iterable<String> labels) {
       return new AutoValue_StarredChangesUtil_StarRef(
@@ -123,7 +121,7 @@
     @Nullable
     public abstract Ref ref();
 
-    public abstract ImmutableSortedSet<String> labels();
+    public abstract NavigableSet<String> labels();
 
     public ObjectId objectId() {
       return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
@@ -158,34 +156,25 @@
   }
 
   public static final String DEFAULT_LABEL = "star";
-  public static final String IGNORE_LABEL = "ignore";
-  public static final ImmutableSortedSet<String> DEFAULT_LABELS =
-      ImmutableSortedSet.of(DEFAULT_LABEL);
 
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<PersonIdent> serverIdent;
-  private final ChangeIndexer indexer;
-  private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   StarredChangesUtil(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      ChangeIndexer indexer,
-      Provider<InternalChangeQuery> queryProvider) {
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
     this.repoManager = repoManager;
     this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.serverIdent = serverIdent;
-    this.indexer = indexer;
-    this.queryProvider = queryProvider;
   }
 
-  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
+  public NavigableSet<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) {
@@ -197,34 +186,27 @@
     }
   }
 
-  public ImmutableSortedSet<String> star(
-      Account.Id accountId,
-      Project.NameKey project,
-      Change.Id changeId,
-      Set<String> labelsToAdd,
-      Set<String> labelsToRemove)
+  public void star(Account.Id accountId, Change.Id changeId, Operation op)
       throws IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
 
-      Set<String> labels = new HashSet<>(old.labels());
-      if (labelsToAdd != null) {
-        labels.addAll(labelsToAdd);
-      }
-      if (labelsToRemove != null) {
-        labels.removeAll(labelsToRemove);
+      NavigableSet<String> labels = new TreeSet<>(old.labels());
+      switch (op) {
+        case ADD:
+          labels.add(DEFAULT_LABEL);
+          break;
+        case REMOVE:
+          labels.remove(DEFAULT_LABEL);
+          break;
       }
 
       if (labels.isEmpty()) {
         deleteRef(repo, refName, old.objectId());
       } else {
-        checkMutuallyExclusiveLabels(labels);
         updateLabels(repo, refName, old.objectId(), labels);
       }
-
-      indexer.index(project, changeId);
-      return ImmutableSortedSet.copyOf(labels);
     } catch (IOException e) {
       throw new StorageException(
           String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
@@ -248,7 +230,7 @@
       batchUpdate.setAllowNonFastForwards(true);
       batchUpdate.setRefLogIdent(serverIdent.get());
       batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
+      for (Account.Id accountId : getStars(repo, changeId)) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
         Ref ref = repo.getRefDatabase().exactRef(refName);
         if (ref != null) {
@@ -274,12 +256,7 @@
   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))) {
-        Integer id = Ints.tryParse(refPart);
-        if (id == null) {
-          continue;
-        }
-        Account.Id accountId = Account.id(id);
+      for (Account.Id accountId : getStars(repo, changeId)) {
         builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
@@ -318,22 +295,15 @@
     }
   }
 
-  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
-    List<ChangeData> changeData =
-        queryProvider
-            .get()
-            .setRequestedFields(ChangeField.ID, ChangeField.STAR)
-            .byLegacyChangeId(changeId);
-    if (changeData.size() != 1) {
-      throw new NoSuchChangeException(changeId);
-    }
-    return changeData.get(0).stars();
-  }
-
-  private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+      throws IOException {
+    String prefix = RefNames.refsStarredChangesPrefix(changeId);
+    RefDatabase refDb = allUsers.getRefDatabase();
     return refDb.getRefsByPrefix(prefix).stream()
         .map(r -> r.getName().substring(prefix.length()))
+        .map(refPart -> Ints.tryParse(refPart))
+        .filter(Objects::nonNull)
+        .map(id -> Account.id(id))
         .collect(toSet());
   }
 
@@ -349,49 +319,29 @@
     }
   }
 
-  public void ignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(IGNORE_LABEL),
-        ImmutableSet.of());
-  }
-
-  public void unignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(),
-        ImmutableSet.of(IGNORE_LABEL));
-  }
-
-  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) {
-    return getLabels(accountId, changeId).contains(IGNORE_LABEL);
-  }
-
-  public boolean isIgnored(ChangeResource rsrc) {
-    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
-  }
-
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
             "Read star labels", Metadata.builder().noteDbRefName(refName).build())) {
       Ref ref = repo.exactRef(refName);
-      if (ref == null) {
-        return StarRef.MISSING;
-      }
+      return readLabels(repo, ref);
+    }
+  }
 
-      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)));
-      }
+  public static StarRef readLabels(Repository repo, Ref ref) throws IOException {
+    if (ref == null) {
+      return StarRef.MISSING;
+    }
+    try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                String.format("Read star labels from %s (without ref lookup)", ref.getName()));
+        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)));
     }
   }
 
@@ -408,19 +358,12 @@
     }
   }
 
-  private static void checkMutuallyExclusiveLabels(Set<String> labels)
-      throws MutuallyExclusiveLabelsException {
-    if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
-    }
-  }
-
   private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
     if (labels == null) {
       return;
     }
 
-    SortedSet<String> invalidLabels = new TreeSet<>();
+    NavigableSet<String> invalidLabels = new TreeSet<>();
     for (String label : labels) {
       if (CharMatcher.whitespace().matchesAnyOf(label)) {
         invalidLabels.add(label);
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 3c69573..58396f5 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -39,23 +39,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.function.Function;
-import java.util.function.Predicate;
 
 @Singleton
 public class WebLinks {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final Predicate<WebLinkInfo> INVALID_WEBLINK =
-      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<ResolveConflictsWebLink> resolveConflictsLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
@@ -98,12 +86,19 @@
    * @param commit SHA1 of commit.
    * @param commitMessage the commit message of the commit.
    * @param branchName branch of the commit.
+   * @param changeKey change Identifier for this change
    */
   public ImmutableList<WebLinkInfo> getPatchSetLinks(
-      Project.NameKey project, String commit, String commitMessage, String branchName) {
+      Project.NameKey project,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey) {
     return filterLinks(
         patchSetLinks,
-        webLink -> webLink.getPatchSetWebLink(project.get(), commit, commitMessage, branchName));
+        webLink ->
+            webLink.getPatchSetWebLink(
+                project.get(), commit, commitMessage, branchName, changeKey));
   }
 
   /**
@@ -154,13 +149,15 @@
    * Returns links for files
    *
    * @param project Project name.
-   * @param revision SHA1 of revision.
+   * @param revision Name of the revision (e.g. branch or commit ID)
+   * @param hash SHA1 of revision.
    * @param file File name.
    */
-  public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileLinks(
+      String project, String revision, String hash, String file) {
     return Patch.isMagic(file)
         ? ImmutableList.of()
-        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
+        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, hash, file));
   }
 
   /**
@@ -177,7 +174,7 @@
     }
     return Streams.stream(fileHistoryLinks)
         .map(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -216,7 +213,7 @@
                     patchSetIdB,
                     revisionB,
                     fileB))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -253,7 +250,17 @@
       DynamicSet<T> links, Function<T, WebLinkInfo> transformer) {
     return Streams.stream(links)
         .map(transformer)
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
+
+  private static boolean isValid(WebLinkInfo 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;
+  }
 }
diff --git a/java/com/google/gerrit/server/account/AccountAttributeLoader.java b/java/com/google/gerrit/server/account/AccountAttributeLoader.java
new file mode 100644
index 0000000..ae57941
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountAttributeLoader.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+
+public class AccountAttributeLoader {
+
+  public interface Factory {
+    AccountAttributeLoader create();
+  }
+
+  private final InternalAccountDirectory directory;
+  private final Map<Account.Id, AccountAttribute> created = new HashMap<>();
+
+  @Inject
+  AccountAttributeLoader(InternalAccountDirectory directory) {
+    this.directory = directory;
+  }
+
+  @Nullable
+  public synchronized AccountAttribute get(@Nullable Account.Id id) {
+    if (id == null) {
+      return null;
+    }
+    return created.computeIfAbsent(id, k -> new AccountAttribute(k.get()));
+  }
+
+  public void fill() {
+    directory.fillAccountAttributeInfo(created.values());
+  }
+}
diff --git a/java/com/google/gerrit/server/account/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
index 54bfa56..5ae7345 100644
--- a/java/com/google/gerrit/server/account/AccountCache.java
+++ b/java/com/google/gerrit/server/account/AccountCache.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
@@ -72,4 +75,18 @@
    *     exists or if loading the external ID fails {@link Optional#empty()} is returned
    */
   Optional<AccountState> getByUsername(String username);
+
+  /**
+   * Returns an {@code AccountState} instance for the given account ID at the given {@code metaId}
+   * of {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref. The method does not populate {@link AccountState#defaultPreferences}.
+   *
+   * @param accountId ID of the account that should be retrieved.
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   * @return {@code AccountState} instance for the given account ID at specific sha1 {@code metaId}.
+   */
+  @UsedAt(Project.GOOGLE)
+  AccountState getFromMetaId(Account.Id accountId, ObjectId metaId);
 }
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 093af68..66a36f6 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -45,6 +45,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -106,6 +107,18 @@
   }
 
   @Override
+  public AccountState getFromMetaId(Account.Id id, ObjectId metaId) {
+    try {
+      CachedAccountDetails.Key key = CachedAccountDetails.Key.create(id, metaId);
+
+      CachedAccountDetails accountDetails = accountDetailsCache.get(key);
+      return AccountState.forCachedAccount(accountDetails, CachedPreferences.EMPTY, externalIds);
+    } catch (IOException | ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
@@ -153,7 +166,7 @@
   }
 
   private AccountState missing(Account.Id accountId) {
-    Account.Builder account = Account.builder(accountId, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(accountId, TimeUtil.now());
     account.setActive(false);
     return AccountState.forAccount(account.build());
   }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 45f1f35..4143f77 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -177,7 +177,7 @@
    * @throws DuplicateKeyException if the user branch already exists
    */
   public Account getNewAccount() throws DuplicateKeyException {
-    return getNewAccount(TimeUtil.nowTs());
+    return getNewAccount(TimeUtil.now());
   }
 
   /**
@@ -186,7 +186,7 @@
    * @return the new account
    * @throws DuplicateKeyException if the user branch already exists
    */
-  Account getNewAccount(Timestamp registeredOn) throws DuplicateKeyException {
+  Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
     checkLoaded();
     if (revision != null) {
       throw new DuplicateKeyException(String.format("account %s already exists", accountId));
@@ -216,7 +216,7 @@
       rw.reset();
       rw.markStart(revision);
       rw.sort(RevSort.REVERSE);
-      Timestamp registeredOn = new Timestamp(rw.next().getCommitTime() * 1000L);
+      Instant registeredOn = Instant.ofEpochMilli(rw.next().getCommitTime() * 1000L);
 
       Config accountConfig = readConfig(AccountProperties.ACCOUNT_CONFIG);
       loadedAccountProperties =
@@ -274,7 +274,7 @@
         commit.setMessage("Create account\n");
       }
 
-      Timestamp registeredOn = loadedAccountProperties.get().getRegisteredOn();
+      Instant registeredOn = loadedAccountProperties.get().getRegisteredOn();
       commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
       commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
     }
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index 3f7f3f2..ca63565 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -83,12 +82,12 @@
      * accounts.
      */
     @UsedAt(UsedAt.Project.PLUGIN_CODE_OWNERS)
-    public AccountControl get(IdentifiedUser identifiedUser) {
+    public AccountControl get(CurrentUser user) {
       return new AccountControl(
           permissionBackend,
           projectCache,
           groupControlFactory,
-          identifiedUser,
+          user,
           userFactory,
           accountVisibility);
     }
@@ -177,7 +176,7 @@
       logger.atFine().log(
           "user %s can see own account %d", user.getLoggableName(), otherUser.getId().get());
       return true;
-    } else if (viewAll()) {
+    } else if (canViewAll()) {
       logger.atFine().log(
           "user %s can see account %d (view all accounts = true)",
           user.getLoggableName(), otherUser.getId().get());
@@ -256,13 +255,10 @@
     }
   }
 
-  private boolean viewAll() {
+  public boolean canViewAll() {
     if (viewAll == null) {
       try {
-        perm.check(GlobalPermission.VIEW_ALL_ACCOUNTS);
-        viewAll = true;
-      } catch (AuthException e) {
-        viewAll = false;
+        viewAll = perm.test(GlobalPermission.VIEW_ALL_ACCOUNTS);
       } catch (PermissionBackendException e) {
         logger.atFine().withCause(e).log(
             "Failed to check %s global capability for user %s",
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index c7f6496..a4d7608 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -117,7 +117,7 @@
         return true;
       }
     } catch (ResourceConflictException e) {
-      logger.atInfo().log("Account %s already deactivated, continuing...", userName);
+      logger.atInfo().withCause(e).log("Account %s already deactivated, continuing...", userName);
     } catch (Exception e) {
       logger.atSevere().withCause(e).log(
           "Error deactivating account: %s (%s) %s",
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 98b2ca9..10aecd3 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.data.AccountAttribute;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.util.Set;
 
@@ -24,7 +25,7 @@
  * <p>Implementations supply data to Gerrit about user accounts.
  */
 public abstract class AccountDirectory {
-  /** Fields to be populated for a REST API response. */
+  /** Fields to be populated for SSH or REST API response. */
   public enum FillOptions {
     /** Full name or username. */
     NAME,
@@ -59,4 +60,6 @@
 
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
       throws PermissionBackendException;
+
+  public abstract void fillAccountAttributeInfo(Iterable<? extends AccountAttribute> in);
 }
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 5549d28..d97563a 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.PermissionRule;
@@ -105,6 +106,7 @@
   }
 
   /** The range of permitted values associated with a label permission. */
+  @Nullable
   public PermissionRange getRange(String permission) {
     if (GlobalCapability.hasRange(permission)) {
       return toRange(permission, getRules(permission));
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 407d2f7..891a467 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -264,14 +264,16 @@
     }
 
     if (!accountUpdates.isEmpty()) {
-      accountsUpdateProvider
-          .get()
-          .update(
-              "Update Account on Login",
-              user.getAccountId(),
-              AccountsUpdate.joinConsumers(accountUpdates))
-          .orElseThrow(
-              () -> new StorageException("Account " + user.getAccountId() + " has been deleted"));
+      Optional<AccountState> updatedAccount =
+          accountsUpdateProvider
+              .get()
+              .update(
+                  "Update Account on Login",
+                  user.getAccountId(),
+                  AccountsUpdate.joinConsumers(accountUpdates));
+      if (!updatedAccount.isPresent()) {
+        throw new StorageException("Account " + user.getAccountId() + " has been deleted");
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 9b7ca81..928d851 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -17,7 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -57,16 +57,13 @@
   public static final String KEY_STATUS = "status";
 
   private final Account.Id accountId;
-  private final Timestamp registeredOn;
+  private final Instant registeredOn;
   private final Config accountConfig;
   private @Nullable ObjectId metaId;
   private Account account;
 
   AccountProperties(
-      Account.Id accountId,
-      Timestamp registeredOn,
-      Config accountConfig,
-      @Nullable ObjectId metaId) {
+      Account.Id accountId, Instant registeredOn, Config accountConfig, @Nullable ObjectId metaId) {
     this.accountId = accountId;
     this.registeredOn = registeredOn;
     this.accountConfig = accountConfig;
@@ -80,7 +77,7 @@
     return account;
   }
 
-  public Timestamp getRegisteredOn() {
+  public Instant getRegisteredOn() {
     return registeredOn;
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 68f5a85..fcfc805 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -27,7 +27,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.CurrentUser;
@@ -133,12 +132,18 @@
     private final String input;
     private final ImmutableList<AccountState> list;
     private final ImmutableList<AccountState> filteredInactive;
+    private final CurrentUser searchedAsUser;
 
     @VisibleForTesting
-    Result(String input, List<AccountState> list, List<AccountState> filteredInactive) {
+    Result(
+        String input,
+        List<AccountState> list,
+        List<AccountState> filteredInactive,
+        CurrentUser searchedAsUser) {
       this.input = requireNonNull(input);
       this.list = canonicalize(list);
       this.filteredInactive = canonicalize(filteredInactive);
+      this.searchedAsUser = requireNonNull(searchedAsUser);
     }
 
     private ImmutableList<AccountState> canonicalize(List<AccountState> list) {
@@ -181,13 +186,21 @@
       }
     }
 
-    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
+    private void ensureSelfIsUniqueIdentifiedUser() throws UnresolvableAccountException {
       ensureUnique();
+      if (!searchedAsUser.isIdentifiedUser()) {
+        throw new UnresolvableAccountException(this);
+      }
+    }
+
+    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
       if (isSelf()) {
+        ensureSelfIsUniqueIdentifiedUser();
         // 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 searchedAsUser.asIdentifiedUser();
       }
+      ensureUnique();
       return userFactory.create(asUnique());
     }
 
@@ -195,11 +208,10 @@
         throws UnresolvableAccountException {
       ensureUnique();
       if (isSelf()) {
-        // TODO(dborowitz): This preserves old behavior, but it seems wrong to discard the caller.
-        return self.get().asIdentifiedUser();
+        return searchedAsUser.asIdentifiedUser();
       }
       return userFactory.runAs(
-          null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
+          /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
     }
 
     @VisibleForTesting
@@ -222,16 +234,57 @@
       return false;
     }
 
+    /**
+     * Searches can be done on behalf of either the current user or another provided user. The
+     * results of some searchers, such as BySelf, are affected by the context user.
+     */
+    default boolean requiresContextUser() {
+      return false;
+    }
+
     Optional<I> tryParse(String input) throws IOException;
 
-    Stream<AccountState> search(I input) throws IOException, ConfigInvalidException;
+    /**
+     * This method should be implemented for every searcher which doesn't require a context user.
+     *
+     * @param input to search for
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input) throws IOException, ConfigInvalidException {
+      throw new IllegalStateException("search(I) default implementation should never be called.");
+    }
+
+    /**
+     * This method should be implemented for every searcher which requires a context user.
+     *
+     * @param input to search for
+     * @param asUser the context user for the search
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input, CurrentUser asUser)
+        throws IOException, ConfigInvalidException {
+      if (!requiresContextUser()) {
+        return search(input);
+      }
+      throw new IllegalStateException(
+          "The searcher requires a context user, but doesn't implement search(input, asUser).");
+    }
 
     boolean shortCircuitIfNoResults();
 
-    default Optional<Stream<AccountState>> trySearch(String input)
+    default Optional<Stream<AccountState>> trySearch(String input, CurrentUser asUser)
         throws IOException, ConfigInvalidException {
       Optional<I> parsed = tryParse(input);
-      return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty();
+      if (parsed.isEmpty()) {
+        return Optional.empty();
+      }
+      return requiresContextUser()
+          ? Optional.of(search(parsed.get(), asUser))
+          : Optional.of(search(parsed.get()));
     }
   }
 
@@ -252,7 +305,7 @@
     }
   }
 
-  private class BySelf extends StringSearcher {
+  private static class BySelf extends StringSearcher {
     @Override
     public boolean callerShouldFilterOutInactiveCandidates() {
       return false;
@@ -264,17 +317,21 @@
     }
 
     @Override
+    public boolean requiresContextUser() {
+      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()) {
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
+      if (!asUser.isIdentifiedUser()) {
         return Stream.empty();
       }
-      return Stream.of(user.asIdentifiedUser().state());
+      return Stream.of(asUser.asIdentifiedUser().state());
     }
 
     @Override
@@ -401,9 +458,20 @@
   }
 
   private class ByFullName implements Searcher<AccountState> {
+    boolean allowSkippingVisibilityCheck = true;
+
+    ByFullName() {
+      super();
+    }
+
+    ByFullName(boolean allowSkippingVisibilityCheck) {
+      this();
+      this.allowSkippingVisibilityCheck = allowSkippingVisibilityCheck;
+    }
+
     @Override
     public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
+      return allowSkippingVisibilityCheck;
     }
 
     @Override
@@ -425,9 +493,25 @@
   }
 
   private class ByDefaultSearch extends StringSearcher {
+    boolean allowSkippingVisibilityCheck = true;
+
+    ByDefaultSearch() {
+      super();
+    }
+
+    ByDefaultSearch(boolean allowSkippingVisibilityCheck) {
+      this();
+      this.allowSkippingVisibilityCheck = allowSkippingVisibilityCheck;
+    }
+
     @Override
     public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
+      return allowSkippingVisibilityCheck;
+    }
+
+    @Override
+    public boolean requiresContextUser() {
+      return true;
     }
 
     @Override
@@ -436,16 +520,17 @@
     }
 
     @Override
-    public Stream<AccountState> search(String input) {
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
       // 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.
       boolean canSeeSecondaryEmails = false;
       try {
-        permissionBackend.user(self.get()).check(GlobalPermission.MODIFY_ACCOUNT);
-        canSeeSecondaryEmails = true;
-      } catch (AuthException | PermissionBackendException e) {
+        if (permissionBackend.user(asUser).test(GlobalPermission.MODIFY_ACCOUNT)) {
+          canSeeSecondaryEmails = true;
+        }
+      } catch (PermissionBackendException e) {
         // remains false
       }
       return accountQueryProvider.get().enforceVisibility(true)
@@ -477,6 +562,18 @@
           .addAll(nameOrEmailSearchers)
           .build();
 
+  private final ImmutableList<Searcher<?>> forcedVisibilitySearchers =
+      ImmutableList.of(
+          new ByNameAndEmail(),
+          new ByEmail(),
+          new FromRealm(),
+          new ByFullName(false),
+          new ByDefaultSearch(false),
+          new BySelf(),
+          new ByExactAccountId(),
+          new ByParenthesizedAccountId(),
+          new ByUsername());
+
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final Emails emails;
@@ -538,12 +635,63 @@
    * @throws IOException if an error occurs.
    */
   public Result resolve(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate());
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
   }
 
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, accountActivityPredicate);
+  }
+
+  /**
+   * Resolves all accounts matching the input string, visible to the provided user.
+   *
+   * <p>The following input formats are recognized:
+   *
+   * <ul>
+   *   <li>The strings {@code "self"} and {@code "me"}, if the provided 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 asUser user to resolve the users by.
+   * @param input input string.
+   * @param forceVisibilityCheck whether to force all searchers to check for visibility.
+   * @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 Result resolveAsUser(CurrentUser asUser, String input, boolean forceVisibilityCheck)
+      throws ConfigInvalidException, IOException {
+    return resolveAsUser(asUser, input, AccountResolver::isActive, forceVisibilityCheck);
+  }
+
+  public Result resolveAsUser(
+      CurrentUser asUser,
+      String input,
+      Predicate<AccountState> accountActivityPredicate,
+      boolean forceVisibilityCheck)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        forceVisibilityCheck ? forcedVisibilitySearchers : searchers,
+        asUser,
+        new ProvidedUserCanSeePredicate(asUser),
+        accountActivityPredicate);
   }
 
   /**
@@ -556,17 +704,23 @@
    * instead will be stored as a link to the corresponding Gerrit Account.
    */
   public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
+    return searchImpl(
+        input,
+        searchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::allVisible);
+  }
+
+  public Result resolveIncludeInactiveIgnoreVisibility(String input)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::allVisible);
   }
 
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
-  }
-
-  public Result resolveIgnoreVisibility(
-      String input, Predicate<AccountState> accountActivityPredicate)
-      throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate);
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
   }
 
   /**
@@ -595,7 +749,11 @@
   @Deprecated
   public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
     return searchImpl(
-        input, nameOrEmailSearchers, visibilitySupplierCanSee(), accountActivityPredicate());
+        input,
+        nameOrEmailSearchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
   }
 
   /**
@@ -614,40 +772,55 @@
     return searchImpl(
         input,
         ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
-        visibilitySupplierCanSee(),
-        accountActivityPredicate());
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierCanSee() {
-    return () -> accountControlFactory.get()::canSee;
+  private Predicate<AccountState> currentUserCanSeePredicate() {
+    return accountControlFactory.get()::canSee;
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierAll() {
-    return () -> all();
+  private class ProvidedUserCanSeePredicate implements Supplier<Predicate<AccountState>> {
+    CurrentUser asUser;
+
+    ProvidedUserCanSeePredicate(CurrentUser asUser) {
+      this.asUser = asUser;
+    }
+
+    @Override
+    public Predicate<AccountState> get() {
+      return accountControlFactory.get(asUser.asIdentifiedUser())::canSee;
+    }
   }
 
-  private Predicate<AccountState> all() {
-    return accountState -> {
-      return true;
-    };
+  private Predicate<AccountState> allVisiblePredicate() {
+    return AccountResolver::allVisible;
   }
 
-  private Predicate<AccountState> accountActivityPredicate() {
-    return (AccountState accountState) -> accountState.account().isActive();
+  /** @param accountState account state for which the visibility should be checked */
+  private static boolean allVisible(AccountState accountState) {
+    return true;
+  }
+
+  private static boolean isActive(AccountState accountState) {
+    return accountState.account().isActive();
   }
 
   @VisibleForTesting
   Result searchImpl(
       String input,
       ImmutableList<Searcher<?>> searchers,
+      CurrentUser asUser,
       Supplier<Predicate<AccountState>> visibilitySupplier,
       Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
+    requireNonNull(asUser);
     visibilitySupplier = Suppliers.memoize(visibilitySupplier::get);
     List<AccountState> inactive = new ArrayList<>();
 
     for (Searcher<?> searcher : searchers) {
-      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input);
+      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input, asUser);
       if (!maybeResults.isPresent()) {
         continue;
       }
@@ -669,22 +842,25 @@
       }
 
       if (!list.isEmpty()) {
-        return createResult(input, list);
+        return createResult(input, list, asUser);
       }
       if (searcher.shortCircuitIfNoResults()) {
         // For a short-circuiting searcher, return results even if empty.
-        return !inactive.isEmpty() ? emptyResult(input, inactive) : createResult(input, list);
+        return !inactive.isEmpty()
+            ? emptyResult(input, inactive, asUser)
+            : createResult(input, list, asUser);
       }
     }
-    return emptyResult(input, inactive);
+    return emptyResult(input, inactive, asUser);
   }
 
-  private Result createResult(String input, List<AccountState> list) {
-    return new Result(input, list, ImmutableList.of());
+  private Result createResult(String input, List<AccountState> list, CurrentUser searchedAsUser) {
+    return new Result(input, list, ImmutableList.of(), searchedAsUser);
   }
 
-  private Result emptyResult(String input, List<AccountState> inactive) {
-    return new Result(input, ImmutableList.of(), inactive);
+  private Result emptyResult(
+      String input, List<AccountState> inactive, CurrentUser searchedAsUser) {
+    return new Result(input, ImmutableList.of(), inactive, searchedAsUser);
   }
 
   private Stream<AccountState> toAccountStates(Set<Account.Id> ids) {
diff --git a/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
index 4fb69bd..9629809 100644
--- a/java/com/google/gerrit/server/account/AccountResource.java
+++ b/java/com/google/gerrit/server/account/AccountResource.java
@@ -23,20 +23,16 @@
 import java.util.Set;
 
 public class AccountResource implements RestResource {
-  public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND =
-      new TypeLiteral<RestView<AccountResource>>() {};
+  public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<Capability>> CAPABILITY_KIND =
-      new TypeLiteral<RestView<Capability>>() {};
+  public static final TypeLiteral<RestView<Capability>> CAPABILITY_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<Email>> EMAIL_KIND =
-      new TypeLiteral<RestView<Email>>() {};
+  public static final TypeLiteral<RestView<Email>> EMAIL_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND =
-      new TypeLiteral<RestView<SshKey>>() {};
+  public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND = new TypeLiteral<>() {};
 
   public static final TypeLiteral<RestView<StarredChange>> STARRED_CHANGE_KIND =
-      new TypeLiteral<RestView<StarredChange>>() {};
+      new TypeLiteral<>() {};
 
   private final IdentifiedUser user;
 
@@ -106,8 +102,7 @@
   }
 
   public static class Star implements RestResource {
-    public static final TypeLiteral<RestView<Star>> STAR_KIND =
-        new TypeLiteral<RestView<Star>>() {};
+    public static final TypeLiteral<RestView<Star>> STAR_KIND = new TypeLiteral<>() {};
 
     private final IdentifiedUser user;
     private final ChangeResource change;
diff --git a/java/com/google/gerrit/server/account/AccountTagProvider.java b/java/com/google/gerrit/server/account/AccountTagProvider.java
index ddb1331..e74bde7 100644
--- a/java/com/google/gerrit/server/account/AccountTagProvider.java
+++ b/java/com/google/gerrit/server/account/AccountTagProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 93738b0..d6ea294 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -49,7 +49,6 @@
 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.List;
 import java.util.Objects;
@@ -61,6 +60,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Creates and updates accounts.
@@ -201,10 +201,7 @@
   /** Single instance that accumulates updates from the batch. */
   private ExternalIdNotes externalIdNotes;
 
-  private static final Runnable DO_NOTHING = () -> {};
-
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -225,12 +222,11 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.empty()),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -252,8 +248,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @VisibleForTesting
@@ -297,9 +293,7 @@
 
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    return user.isPresent()
-        ? user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())
-        : serverIdent;
+    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
   }
 
   /**
@@ -330,9 +324,7 @@
             ImmutableList.of(
                 repo -> {
                   AccountConfig accountConfig = read(repo, accountId);
-                  Account account =
-                      accountConfig.getNewAccount(
-                          new Timestamp(committerIdent.getWhen().getTime()));
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
                   init.configure(accountState, deltaBuilder);
@@ -516,23 +508,31 @@
         updatedAccounts.size() == 1
             ? Iterables.getOnlyElement(updatedAccounts).message
             : "Batch update for " + updatedAccounts.size() + " accounts";
+    ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
+    // These update the same ref, so they need to be stacked on top of one another using the same
+    // ExternalIdNotes instance.
+    RevCommit revCommit =
+        commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+    boolean externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
     for (UpdatedAccount updatedAccount : updatedAccounts) {
+
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
       // revision in their AccountConfig.
+      // We allow empty commits:
+      // 1) When creating a new account, so that the user branch gets created with an empty commit
+      // when no account properties are set and hence no
+      // 'account.config' file will be created.
+      // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
+      // This allows to schedule reindexing of account transactionally  on refs/users/* meta
+      // updates.
+      boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
       commitAccountConfig(
           updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
           updatedAccount.accountConfig,
-          updatedAccount.created /* allowEmptyCommit */);
-      // When creating a new account we must allow empty commits so that the user branch gets
-      // created with an empty commit when no account properties are set and hence no
-      // 'account.config' file will be created.
-
-      // These update the same ref, so they need to be stacked on top of one another using the same
-      // ExternalIdNotes instance.
-      commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+          allowEmptyCommit);
     }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
@@ -565,10 +565,10 @@
     }
   }
 
-  private void commitExternalIdUpdates(
+  private RevCommit commitExternalIdUpdates(
       String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
     try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      externalIdNotes.commit(md);
+      return externalIdNotes.commit(md);
     }
   }
 
@@ -586,6 +586,8 @@
     return metaDataUpdate;
   }
 
+  private static void doNothing() {}
+
   @FunctionalInterface
   private interface ExecutableUpdate {
     UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
diff --git a/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java
index cceda70..b4fbcdb 100644
--- a/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -102,6 +102,7 @@
     return externalId;
   }
 
+  @Nullable
   public String getLocalUser() {
     if (externalId.isScheme(SCHEME_GERRIT)) {
       return externalId.id();
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index f23a766..2ab6174 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.config.CachedPreferences;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -107,7 +106,7 @@
       Cache.AccountProto.Builder accountProto =
           Cache.AccountProto.newBuilder()
               .setId(account.id().get())
-              .setRegisteredOn(account.registeredOn().toInstant().toEpochMilli())
+              .setRegisteredOn(account.registeredOn().toEpochMilli())
               .setInactive(account.inactive())
               .setFullName(Strings.nullToEmpty(account.fullName()))
               .setDisplayName(Strings.nullToEmpty(account.displayName()))
@@ -143,7 +142,7 @@
       Account account =
           Account.builder(
                   Account.id(proto.getAccount().getId()),
-                  Timestamp.from(Instant.ofEpochMilli(proto.getAccount().getRegisteredOn())))
+                  Instant.ofEpochMilli(proto.getAccount().getRegisteredOn()))
               .setFullName(Strings.emptyToNull(proto.getAccount().getFullName()))
               .setDisplayName(Strings.emptyToNull(proto.getAccount().getDisplayName()))
               .setPreferredEmail(Strings.emptyToNull(proto.getAccount().getPreferredEmail()))
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index ba58c3f..9d9fe9d 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import java.util.Collection;
@@ -30,6 +31,7 @@
     return groupName;
   }
 
+  @Nullable
   public String getGroupName() {
     return groupName != null ? groupName.get() : null;
   }
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index 329825f..cfffceb 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -16,6 +16,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
@@ -79,6 +80,7 @@
   @Override
   public void onCreateAccount(AuthRequest who, Account account) {}
 
+  @Nullable
   @Override
   public Account.Id lookup(String accountName) throws IOException {
     if (emailExpander.canExpand(accountName)) {
diff --git a/java/com/google/gerrit/server/account/DestinationList.java b/java/com/google/gerrit/server/account/DestinationList.java
index 15c1e25..084a3ac 100644
--- a/java/com/google/gerrit/server/account/DestinationList.java
+++ b/java/com/google/gerrit/server/account/DestinationList.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
@@ -39,6 +40,7 @@
     destinations.replaceValues(label, toSet(parse(text, DIR_NAME + label, TRIM, null, errors)));
   }
 
+  @Nullable
   String asText(String label) {
     Set<BranchNameKey> dests = destinations.get(label);
     if (dests == null) {
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 98d0d50..8c3f033 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -29,7 +29,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -118,7 +117,7 @@
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
-    u.setDate(new Timestamp(who.getWhen().getTime()));
+    u.setDate(who.getWhenAsInstant());
     u.setTimeZone(who.getTimeZoneOffset());
 
     // If only one account has access to this email address, select it
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 1e28d7d..46c730c 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
@@ -62,6 +66,22 @@
   Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids);
 
   /**
+   * Returns an {@code InternalGroup} instance for the given {@code AccountGroup.UUID} at the given
+   * {@code metaId} of {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref.
+   *
+   * @param groupUuid the UUID of the internal group
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   * @return the internal group at specific sha1 {@code metaId}
+   * @throws StorageException if no internal group with this UUID exists on this server at the
+   *     specific sha1, or if an error occurred during lookup.
+   */
+  @UsedAt(Project.GOOGLE)
+  InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId) throws StorageException;
+
+  /**
    * Removes the association of the given ID with a group.
    *
    * <p>The next call to {@link #get(AccountGroup.Id)} won't provide a cached value.
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index eaec9ba..6f4fce9 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -23,9 +23,11 @@
 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.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache;
@@ -121,15 +123,19 @@
   private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
   private final LoadingCache<String, Optional<InternalGroup>> byName;
   private final LoadingCache<String, Optional<InternalGroup>> byUUID;
+  private final LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache;
 
   @Inject
   GroupCacheImpl(
       @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
       @Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
-      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID) {
+      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
+      @Named(BYUUID_NAME_PERSISTED)
+          LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache) {
     this.byId = byId;
     this.byName = byName;
     this.byUUID = byUUID;
+    this.persistedByUuidCache = persistedByUuidCache;
   }
 
   @Override
@@ -184,6 +190,21 @@
   }
 
   @Override
+  public InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId)
+      throws StorageException {
+    Cache.GroupKeyProto key =
+        Cache.GroupKeyProto.newBuilder()
+            .setUuid(groupUuid.get())
+            .setRevision(ObjectIdConverter.create().toByteString(metaId))
+            .build();
+    try {
+      return persistedByUuidCache.get(key);
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
       logger.atFine().log("Evict group %s by ID", groupId.get());
@@ -346,6 +367,7 @@
       return Protos.toByteArray(InternalGroupSerializer.serialize(value));
     }
 
+    @Nullable
     @Override
     public InternalGroup deserialize(byte[] in) {
       if (Strings.fromByteArray(in).isEmpty()) {
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index d42db60..fd18d3e 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -189,10 +188,7 @@
 
   private boolean canAdministrateServer() {
     try {
-      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException e) {
-      return false;
+      return perm.test(GlobalPermission.ADMINISTRATE_SERVER);
     } catch (PermissionBackendException e) {
       logger.atFine().log(
           "Failed to check %s global capability for user %s",
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 130fa44..b895834 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -27,11 +27,11 @@
 import com.google.gerrit.extensions.common.AvatarInfo;
 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.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.data.AccountAttribute;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -52,6 +52,9 @@
 @Singleton
 public class InternalAccountDirectory extends AccountDirectory {
   static final Set<FillOptions> ID_ONLY = Collections.unmodifiableSet(EnumSet.of(FillOptions.ID));
+  static final Set<FillOptions> ALL_ACCOUNT_ATTRIBUTES =
+      Collections.unmodifiableSet(
+          EnumSet.of(FillOptions.NAME, FillOptions.EMAIL, FillOptions.USERNAME));
 
   public static class InternalAccountDirectoryModule extends AbstractModule {
     @Override
@@ -97,12 +100,8 @@
     Account.Id currentUserId = null;
     if (self.get().isIdentifiedUser()) {
       currentUserId = self.get().getAccountId();
-
-      try {
-        permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+      if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
         canModifyAccount = true;
-      } catch (AuthException e) {
-        canModifyAccount = false;
       }
     }
 
@@ -129,6 +128,44 @@
     }
   }
 
+  @Override
+  public void fillAccountAttributeInfo(Iterable<? extends AccountAttribute> in) {
+    Set<Account.Id> ids = stream(in).map(a -> Account.id(a.accountId)).collect(toSet());
+    Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
+    for (AccountAttribute accountAttribute : in) {
+      Account.Id id = Account.id(accountAttribute.accountId);
+      AccountState accountState = accountStates.get(id);
+      if (accountState != null) {
+        fill(accountAttribute, accountState, ALL_ACCOUNT_ATTRIBUTES);
+      } else {
+        accountAttribute.accountId = null;
+      }
+    }
+  }
+
+  private void fill(
+      AccountAttribute accountAttribute, AccountState accountState, Set<FillOptions> options) {
+    Account account = accountState.account();
+    if (options.contains(FillOptions.NAME)) {
+      accountAttribute.name = Strings.emptyToNull(account.fullName());
+      if (accountAttribute.name == null) {
+        accountAttribute.name = accountState.userName().orElse(null);
+      }
+    }
+    if (options.contains(FillOptions.EMAIL)) {
+      accountAttribute.email = account.preferredEmail();
+    }
+    if (options.contains(FillOptions.USERNAME)) {
+      accountAttribute.username = accountState.userName().orElse(null);
+    }
+    if (options.contains(FillOptions.ID)) {
+      accountAttribute.accountId = account.id().get();
+    } else {
+      // Was previously set to look up account for filling.
+      accountAttribute.accountId = null;
+    }
+  }
+
   private void fill(AccountInfo info, AccountState accountState, Set<FillOptions> options) {
     Account account = accountState.account();
     if (options.contains(FillOptions.ID)) {
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index 91fe701..01254a0 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -60,6 +61,7 @@
     return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
   }
 
+  @Nullable
   @Override
   public GroupDescription.Internal get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 42137c1..86132d3 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -201,6 +201,7 @@
 
   @AutoValue
   public abstract static class NotifyValue {
+    @Nullable
     public static NotifyValue parse(
         Account.Id accountId,
         String project,
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 3f642f7..ffc95a3 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -56,7 +56,14 @@
    */
   Account.Id lookup(String accountName) throws IOException;
 
-  /** Returns true if the account is active. */
+  /**
+   * Returns true if the account is active.
+   *
+   * @throws LoginException thrown if login is required and fails
+   * @throws NamingException may be thrown if the name is invalid
+   * @throws AccountException may be thrown in case the username is ambiguous
+   * @throws IOException thrown in case of IO errors
+   */
   default boolean isActive(@SuppressWarnings("unused") String username)
       throws LoginException, NamingException, AccountException, IOException {
     return true;
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index 4b68198..5babebd 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -55,26 +55,30 @@
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyInactive = new AtomicBoolean(false);
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Deactivate Account via API",
-            accountId,
-            (a, u) -> {
-              if (!a.account().isActive()) {
-                alreadyInactive.set(true);
-              } else {
-                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);
-              }
-            })
-        .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+    Optional<AccountState> updatedAccount =
+        accountsUpdateProvider
+            .get()
+            .update(
+                "Deactivate Account via API",
+                accountId,
+                (a, u) -> {
+                  if (!a.account().isActive()) {
+                    alreadyInactive.set(true);
+                  } else {
+                    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);
+                  }
+                });
+    if (!updatedAccount.isPresent()) {
+      throw new ResourceNotFoundException("account not found");
+    }
+
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
@@ -94,26 +98,30 @@
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyActive = new AtomicBoolean(false);
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Activate Account via API",
-            accountId,
-            (a, u) -> {
-              if (a.account().isActive()) {
-                alreadyActive.set(true);
-              } else {
-                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);
-              }
-            })
-        .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+    Optional<AccountState> updatedAccount =
+        accountsUpdateProvider
+            .get()
+            .update(
+                "Activate Account via API",
+                accountId,
+                (a, u) -> {
+                  if (a.account().isActive()) {
+                    alreadyActive.set(true);
+                  } else {
+                    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);
+                  }
+                });
+    if (!updatedAccount.isPresent()) {
+      throw new ResourceNotFoundException("account not found");
+    }
+
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 5bd9bea..476ca79 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,10 +27,16 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,11 +55,57 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Field<String> SYSTEM_FIELD =
+      Field.ofString("system", Metadata.Builder::groupSystem).build();
+
   private final PluginSetContext<GroupBackend> backends;
+  private final Counter1<String> handlesCount;
+  private final Counter1<String> getCount;
+  private final Counter2<String, Integer> suggestCount;
+  private final Counter2<String, Boolean> containsCount;
+  private final Counter2<String, Boolean> containsAnyCount;
+  private final Counter2<String, Integer> intersectionCount;
+  private final Counter2<String, Integer> knownGroupsCount;
 
   @Inject
-  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends, MetricMaker metricMaker) {
     this.backends = backends;
+    this.handlesCount =
+        metricMaker.newCounter(
+            "group/handles_count", new Description("Calls to GroupBackend.handles"), SYSTEM_FIELD);
+    this.getCount =
+        metricMaker.newCounter(
+            "group/get_count", new Description("Calls to GroupBackend.get"), SYSTEM_FIELD);
+    this.suggestCount =
+        metricMaker.newCounter(
+            "group/suggest_count",
+            new Description("Calls to GroupBackend.suggest"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_suggested", (meta, value) -> {}).build());
+    this.containsCount =
+        metricMaker.newCounter(
+            "group/contains_count",
+            new Description("Calls to GroupMemberships.contains"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains", (meta, value) -> {}).build());
+    this.containsAnyCount =
+        metricMaker.newCounter(
+            "group/contains_any_of_count",
+            new Description("Calls to GroupMemberships.containsAnyOf"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains_any_of", (meta, value) -> {}).build());
+    this.intersectionCount =
+        metricMaker.newCounter(
+            "group/intersection_count",
+            new Description("Calls to GroupMemberships.intersection"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_intersection", (meta, value) -> {}).build());
+    this.knownGroupsCount =
+        metricMaker.newCounter(
+            "group/known_groups_count",
+            new Description("Calls to GroupMemberships.getKnownGroups"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_known_groups", (meta, value) -> {}).build());
   }
 
   @Nullable
@@ -70,9 +122,15 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return backend(uuid) != null;
+    GroupBackend b = backend(uuid);
+    if (b == null) {
+      return false;
+    }
+    handlesCount.increment(name(b));
+    return true;
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (uuid == null) {
@@ -83,13 +141,19 @@
       logger.atFine().log("Unknown GroupBackend for UUID: %s", uuid);
       return null;
     }
+    getCount.increment(name(b));
     return b.get(uuid);
   }
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
+    backends.runEach(
+        g -> {
+          Collection<GroupReference> suggestions = g.suggest(name, project);
+          suggestCount.increment(name(g), suggestions.size());
+          groups.addAll(suggestions);
+        });
     return groups;
   }
 
@@ -108,11 +172,11 @@
     }
 
     @Nullable
-    private GroupMembership membership(AccountGroup.UUID uuid) {
+    private Map.Entry<GroupBackend, GroupMembership> membership(AccountGroup.UUID uuid) {
       if (uuid != null) {
         for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
           if (m.getKey().handles(uuid)) {
-            return m.getValue();
+            return m;
           }
         }
       }
@@ -125,51 +189,57 @@
       if (uuid == null) {
         return false;
       }
-      GroupMembership m = membership(uuid);
+      Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
       if (m == null) {
         return false;
       }
-      return m.contains(uuid);
+      boolean contains = m.getValue().contains(uuid);
+      containsCount.increment(name(m.getKey()), contains);
+      return contains;
     }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           continue;
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        GroupMembership m = entry.getKey();
-        Collection<AccountGroup.UUID> ids = entry.getValue();
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackends : lookups.asMap().keySet()) {
+
+        GroupMembership m = groupBackends.getValue();
+        Collection<AccountGroup.UUID> ids = lookups.asMap().get(groupBackends);
         if (ids.size() == 1) {
           if (m.contains(Iterables.getOnlyElement(ids))) {
+            containsAnyCount.increment(name(groupBackends.getKey()), true);
             return true;
           }
         } else if (m.containsAnyOf(ids)) {
+          containsAnyCount.increment(name(groupBackends.getKey()), true);
           return true;
         }
+        // We would have returned if contains was true.
+        containsAnyCount.increment(name(groupBackends.getKey()), false);
       }
       return false;
     }
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           logger.atFine().log("Unknown GroupMembership for UUID: %s", uuid);
           continue;
@@ -177,9 +247,11 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        groups.addAll(entry.getKey().intersection(entry.getValue()));
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackend : lookups.asMap().keySet()) {
+        Set<AccountGroup.UUID> intersection =
+            groupBackend.getValue().intersection(lookups.asMap().get(groupBackend));
+        intersectionCount.increment(name(groupBackend.getKey()), intersection.size());
+        groups.addAll(intersection);
       }
       return groups;
     }
@@ -187,8 +259,10 @@
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (GroupMembership m : memberships.values()) {
-        groups.addAll(m.getKnownGroups());
+      for (Map.Entry<GroupBackend, GroupMembership> entry : memberships.entrySet()) {
+        Set<AccountGroup.UUID> knownGroups = entry.getValue().getKnownGroups();
+        knownGroupsCount.increment(name(entry.getKey()), knownGroups.size());
+        groups.addAll(knownGroups);
       }
       return groups;
     }
@@ -204,6 +278,13 @@
     return false;
   }
 
+  private static String name(GroupBackend backend) {
+    if (backend == null) {
+      return "none";
+    }
+    return backend.getClass().getSimpleName();
+  }
+
   public static class ConfigCheck implements StartupCheck {
     private final Config cfg;
     private final UniversalGroupBackend universalGroupBackend;
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 555a2c1..1fce3d5 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -20,6 +20,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
@@ -194,6 +195,7 @@
    * @return the SSH key, <code>null</code> if there is no SSH key with this sequence number, or if
    *     the SSH key with this sequence number has been deleted
    */
+  @Nullable
   private AccountSshKey getKey(int seq) {
     checkLoaded();
     return keys.get(seq - 1).orElse(null);
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index e718bcb..14aa368 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -45,6 +45,13 @@
     return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
   }
 
+  static AllExternalIds create(
+      ImmutableMap<ExternalId.Key, ExternalId> byKey,
+      ImmutableSetMultimap<Account.Id, ExternalId> byAccount,
+      ImmutableSetMultimap<String, ExternalId> byEmail) {
+    return new AutoValue_AllExternalIds(byKey, byAccount, byEmail);
+  }
+
   public abstract ImmutableMap<ExternalId.Key, ExternalId> byKey();
 
   public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
index 1cd3de8..2d1ec1a 100644
--- a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -20,7 +20,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -36,13 +35,6 @@
   }
 
   @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {}
-
-  @Override
   public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 30f4094..1616198 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -106,8 +106,10 @@
   static final String PASSWORD_KEY = "password";
 
   /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   * Scheme used to label accounts created, when using the LDAP-based authentication types {@link
+   * AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link AuthType#HTTP_LDAP}, and {@link
+   * AuthType#LDAP_BIND}. The external ID stores the username. Accounts with such an external ID
+   * will be authenticated against the configured LDAP identity provider.
    *
    * <p>The name {@code gerrit:} was a very poor choice.
    *
@@ -133,6 +135,9 @@
   /** Scheme used for GPG public keys. */
   public static final String SCHEME_GPGKEY = "gpgkey";
 
+  /** Scheme for imported accounts from other servers with different GerritServerId */
+  public static final String SCHEME_IMPORTED = "imported";
+
   /** Scheme for external auth used during authentication, e.g. OAuth Token */
   public static final String SCHEME_EXTERNAL = "external";
 
@@ -186,17 +191,26 @@
       return scheme.equals(scheme());
     }
 
+    @Memoized
+    public ObjectId sha1() {
+      return sha1(isCaseInsensitive());
+    }
+
     /**
      * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
      * notes branch.
      */
     @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
-    @Memoized
-    public ObjectId sha1() {
-      String keyString = isCaseInsensitive() ? get().toLowerCase(Locale.US) : get();
+    private ObjectId sha1(Boolean isCaseInsensitive) {
+      String keyString = isCaseInsensitive ? get().toLowerCase(Locale.US) : get();
       return ObjectId.fromRaw(Hashing.sha1().hashString(keyString, UTF_8).asBytes());
     }
 
+    @Memoized
+    public ObjectId caseSensitiveSha1() {
+      return sha1(false);
+    }
+
     /**
      * Exports this external ID key as string with the format "scheme:id", or "id" if scheme is
      * null.
@@ -299,7 +313,7 @@
     ExternalId o = (ExternalId) obj;
     return Objects.equals(key(), o.key())
         && Objects.equals(accountId(), o.accountId())
-        && Objects.equals(isCaseInsensitive(), o.isCaseInsensitive())
+        && isCaseInsensitive() == o.isCaseInsensitive()
         && Objects.equals(email(), o.email())
         && Objects.equals(password(), o.password());
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 0029557..fe8feac 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -31,22 +30,7 @@
  *
  * <p>All returned collections are unmodifiable.
  */
-interface ExternalIdCache {
-
-  /**
-   * Updates the cache.
-   *
-   * @param oldNotesRev current revision against which the below updates are applied
-   * @param newNotesRev key for the new cache revision
-   * @param toRemove external IDs to remove
-   * @param toAdd external IDs to add
-   */
-  void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd);
-
+public interface ExternalIdCache {
   Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
 
   ImmutableSet<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 e6db593..af8e19f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -14,66 +14,42 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
-import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
 @Singleton
 class ExternalIdCacheImpl implements ExternalIdCache {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public static final String CACHE_NAME = "external_ids_map";
 
-  private final LoadingCache<ObjectId, AllExternalIds> extIdsByAccount;
+  private final Cache<ObjectId, AllExternalIds> extIdsByAccount;
   private final ExternalIdReader externalIdReader;
+  private final ExternalIdCacheLoader externalIdCacheLoader;
   private final Lock lock;
 
   @Inject
   ExternalIdCacheImpl(
-      @Named(CACHE_NAME) LoadingCache<ObjectId, AllExternalIds> extIdsByAccount,
-      ExternalIdReader externalIdReader) {
+      @Named(CACHE_NAME) Cache<ObjectId, AllExternalIds> extIdsByAccount,
+      ExternalIdReader externalIdReader,
+      ExternalIdCacheLoader externalIdCacheLoader) {
     this.extIdsByAccount = extIdsByAccount;
     this.externalIdReader = externalIdReader;
+    this.externalIdCacheLoader = externalIdCacheLoader;
     this.lock = new ReentrantLock(true /* fair */);
   }
 
   @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          for (ExternalId extId : toRemove) {
-            m.remove(extId.accountId(), extId);
-          }
-          for (ExternalId extId : toAdd) {
-            extId.checkThatBlobIdIsSet();
-            m.put(extId.accountId(), extId);
-          }
-        });
-  }
-
-  @Override
   public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
     return Optional.ofNullable(get().byKey().get(key));
   }
@@ -112,38 +88,39 @@
     return get(externalIdReader.readRevision());
   }
 
+  /**
+   * Returns the cached value or a freshly loaded value that will be cached with this call in case
+   * the value was absent from the cache.
+   *
+   * <p>This method will load the value using {@link ExternalIdCacheLoader} in case it is not
+   * already cached. {@link ExternalIdCacheLoader} requires loading older versions of the cached
+   * value and Caffeine does not support recursive calls to the cache from loaders. Hence, we use a
+   * Cache instead of a LoadingCache and perform the loading ourselves here similar to what a
+   * loading cache would do.
+   */
   private AllExternalIds get(ObjectId rev) throws IOException {
-    try {
-      return extIdsByAccount.get(rev);
-    } catch (ExecutionException e) {
-      throw new IOException("Cannot load external ids", e);
-    }
-  }
-
-  private void updateCache(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      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;
+    AllExternalIds cachedValue = extIdsByAccount.getIfPresent(rev);
+    if (cachedValue != null) {
+      return cachedValue;
     }
 
+    // Load the value and put it in the cache.
     lock.lock();
     try {
-      SetMultimap<Account.Id, ExternalId> m;
-      if (!ObjectId.zeroId().equals(oldNotesRev)) {
-        m =
-            MultimapBuilder.hashKeys()
-                .hashSetValues()
-                .build(extIdsByAccount.get(oldNotesRev).byAccount());
-      } else {
-        m = MultimapBuilder.hashKeys().hashSetValues().build();
+      // Check if value was already loaded while waiting for the lock.
+      cachedValue = extIdsByAccount.getIfPresent(rev);
+      if (cachedValue != null) {
+        return cachedValue;
       }
-      update.accept(m);
-      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m.values().stream()));
-    } catch (ExecutionException e) {
-      logger.atWarning().withCause(e).log("Cannot update external IDs");
+
+      AllExternalIds newlyLoadedValue;
+      try {
+        newlyLoadedValue = externalIdCacheLoader.load(rev);
+      } catch (ConfigInvalidException e) {
+        throw new IOException("Cannot load external ids", e);
+      }
+      extIdsByAccount.put(rev, newlyLoadedValue);
+      return newlyLoadedValue;
     } finally {
       lock.unlock();
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
index 72d703b..1edb284 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.cache.Cache;
-import com.google.common.cache.CacheLoader;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
@@ -36,7 +35,6 @@
 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;
@@ -58,7 +56,7 @@
 
 /** Loads cache values for the external ID cache using either a full or a partial reload. */
 @Singleton
-public class ExternalIdCacheLoader extends CacheLoader<ObjectId, AllExternalIds> {
+public class ExternalIdCacheLoader {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   // Maximum number of prior states we inspect to find a base for differential. If no cached state
@@ -66,12 +64,11 @@
   private static final int MAX_HISTORY_LOOKBACK = 10;
 
   private final ExternalIdReader externalIdReader;
-  private final Provider<Cache<ObjectId, AllExternalIds>> externalIdCache;
+  private final 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;
   private final boolean isPersistentCache;
   private final ExternalIdFactory externalIdFactory;
 
@@ -80,8 +77,7 @@
       GitRepositoryManager gitRepositoryManager,
       AllUsersName allUsersName,
       ExternalIdReader externalIdReader,
-      @Named(ExternalIdCacheImpl.CACHE_NAME)
-          Provider<Cache<ObjectId, AllExternalIds>> externalIdCache,
+      @Named(ExternalIdCacheImpl.CACHE_NAME) Cache<ObjectId, AllExternalIds> externalIdCache,
       MetricMaker metricMaker,
       @GerritServerConfig Config config,
       ExternalIdFactory externalIdFactory) {
@@ -105,23 +101,13 @@
                     "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", true);
     this.isPersistentCache =
         config.getInt("cache", ExternalIdCacheImpl.CACHE_NAME, "diskLimit", 0) > 0;
     this.externalIdFactory = externalIdFactory;
   }
 
-  @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);
-    }
-
+    externalIdReader.checkReadEnabled();
     // 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.
@@ -158,7 +144,7 @@
       while ((parentWithCacheValue = rw.next()) != null
           && i++ < MAX_HISTORY_LOOKBACK
           && parentWithCacheValue.getParentCount() < 2) {
-        oldExternalIds = externalIdCache.get().getIfPresent(parentWithCacheValue.getId());
+        oldExternalIds = externalIdCache.getIfPresent(parentWithCacheValue.getId());
         if (oldExternalIds != null) {
           // We found a previously cached state.
           break;
@@ -267,7 +253,7 @@
         }
       }
     }
-    return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
+    return AllExternalIds.create(byKey.build(), byAccount.build(), byEmail.build());
   }
 
   private AllExternalIds reloadAllExternalIds(ObjectId notesRev)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
index f0ad1b2..1873ea0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
@@ -35,7 +35,6 @@
         // Guava calls the loader first and evicts later on.
         .maximumWeight(2)
         .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(ExternalIdCacheLoader.class)
         .diskLimit(-1)
         .version(1)
         .keySerializer(ObjectIdCacheSerializer.INSTANCE)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
new file mode 100644
index 0000000..a6ee366c
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+
+public class ExternalIdCaseSensitivityMigrator {
+
+  public static class ExternalIdCaseSensitivityMigratorModule extends AbstractModule {
+    @Override
+    public void configure() {
+      install(new FactoryModuleBuilder().build(ExternalIdCaseSensitivityMigrator.Factory.class));
+    }
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    ExternalIdCaseSensitivityMigrator create(
+        @Assisted("isUserNameCaseInsensitive") Boolean isUserNameCaseInsensitive,
+        @Assisted("dryRun") Boolean dryRun);
+  }
+
+  private GitRepositoryManager repoManager;
+  private AllUsersName allUsersName;
+  private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+  private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
+
+  private ExternalIdFactory externalIdFactory;
+  private Boolean isUserNameCaseInsensitive;
+  private Boolean dryRun;
+
+  @Inject
+  public ExternalIdCaseSensitivityMigrator(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
+      ExternalIdNotes.FactoryNoReindex externalIdNotesFactory,
+      ExternalIdFactory externalIdFactory,
+      @Assisted("isUserNameCaseInsensitive") Boolean isUserNameCaseInsensitive,
+      @Assisted("dryRun") Boolean dryRun) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
+    this.externalIdNotesFactory = externalIdNotesFactory;
+    this.externalIdFactory = externalIdFactory;
+
+    this.isUserNameCaseInsensitive = isUserNameCaseInsensitive;
+    this.dryRun = dryRun;
+  }
+
+  private void recomputeExternalIdNoteId(ExternalIdNotes extIdNotes, ExternalId extId)
+      throws DuplicateKeyException, IOException {
+    if (extId.isScheme(SCHEME_GERRIT) || extId.isScheme(SCHEME_USERNAME)) {
+      ExternalIdKeyFactory keyFactory =
+          new ExternalIdKeyFactory(
+              new ExternalIdKeyFactory.Config() {
+                @Override
+                public boolean isUserNameCaseInsensitive() {
+                  return isUserNameCaseInsensitive;
+                }
+              });
+      ExternalId.Key updatedKey = keyFactory.create(extId.key().scheme(), extId.key().id());
+      ExternalId.Key oldKey =
+          keyFactory.create(extId.key().scheme(), extId.key().id(), !isUserNameCaseInsensitive);
+      if (!oldKey.sha1().getName().equals(updatedKey.sha1().getName())
+          && !extId.key().sha1().getName().equals(updatedKey.sha1().getName())) {
+        logger.atInfo().log("Converting note name of external ID: %s", oldKey);
+        ExternalId updatedExtId =
+            externalIdFactory.create(
+                updatedKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
+        ExternalId oldExtId =
+            externalIdFactory.create(
+                oldKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
+        extIdNotes.replace(
+            Collections.singleton(oldExtId),
+            Collections.singleton(updatedExtId),
+            (externalId) -> externalId.key().sha1());
+      }
+    }
+  }
+
+  public void migrate(Collection<ExternalId> todo, Runnable monitor)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+      for (ExternalId extId : todo) {
+        recomputeExternalIdNoteId(extIdNotes, extId);
+        monitor.run();
+      }
+      if (!dryRun) {
+        try (MetaDataUpdate metaDataUpdate =
+            metaDataUpdateServerFactory.get().create(allUsersName)) {
+          metaDataUpdate.setMessage(
+              String.format(
+                  "Migration to case %ssensitive usernames",
+                  isUserNameCaseInsensitive ? "" : "in"));
+          extIdNotes.commit(metaDataUpdate);
+        } catch (Exception e) {
+          logger.atSevere().withCause(e).log("%s", e.getMessage());
+        }
+      }
+    } catch (DuplicateExternalIdKeyException e) {
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
+      throw e;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
index 502bab9..b16f73f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -19,10 +19,10 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AuthConfig;
 import java.util.Set;
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -32,12 +32,13 @@
 
 @Singleton
 public class ExternalIdFactory {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final ExternalIdKeyFactory externalIdKeyFactory;
+  private AuthConfig authConfig;
 
   @Inject
-  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory) {
+  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
     this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
   }
 
   /**
@@ -250,10 +251,23 @@
     }
 
     if (!externalIdKey.sha1().getName().equals(noteId)) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+      if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+      }
+
+      if (!externalIdKey.caseSensitiveSha1().getName().equals(noteId)) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
+                    + " '%s'",
+                externalIdKeyStr, noteId));
+      }
+      externalIdKey =
+          externalIdKeyFactory.create(externalIdKey.scheme(), externalIdKey.id(), false);
     }
 
     String email =
@@ -301,15 +315,17 @@
       }
       return accountId;
     } catch (IllegalArgumentException e) {
-      String msg =
-          String.format(
-              "Value %s for '%s.%s.%s' is invalid, expected account ID",
-              accountIdStr,
-              ExternalId.EXTERNAL_ID_SECTION,
-              externalIdKeyStr,
-              ExternalId.ACCOUNT_ID_KEY);
-      logger.atSevere().withCause(e).log(msg);
-      throw invalidConfig(noteId, msg);
+      ConfigInvalidException newException =
+          invalidConfig(
+              noteId,
+              String.format(
+                  "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                  accountIdStr,
+                  ExternalId.EXTERNAL_ID_SECTION,
+                  externalIdKeyStr,
+                  ExternalId.ACCOUNT_ID_KEY));
+      newException.initCause(e);
+      throw newException;
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
index 95df4a9..68d8b0c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
@@ -65,9 +65,23 @@
    * @return the created external ID key
    */
   public ExternalId.Key create(@Nullable String scheme, String id) {
+    return create(scheme, id, isUserNameCaseInsensitive);
+  }
+
+  /**
+   * Creates an external ID key.
+   *
+   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
+   *     ExternalId#SCHEME_USERNAME}.
+   * @param id the external ID, must not contain colons (':')
+   * @param userNameCaseInsensitive whether the external ID key is matched case insensitively
+   * @return the created external ID key
+   */
+  public ExternalId.Key create(
+      @Nullable String scheme, String id, boolean userNameCaseInsensitive) {
     if (scheme != null
         && (scheme.equals(ExternalId.SCHEME_USERNAME) || scheme.equals(ExternalId.SCHEME_GERRIT))) {
-      return ExternalId.Key.create(scheme, id, isUserNameCaseInsensitive);
+      return ExternalId.Key.create(scheme, id, userNameCaseInsensitive);
     }
 
     return ExternalId.Key.create(scheme, id, false);
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 2b9c00a9..48c403c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -53,6 +54,7 @@
 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.BlobBasedConfig;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -100,18 +102,21 @@
     protected final AllUsersName allUsersName;
     protected final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
     protected final ExternalIdFactory externalIdFactory;
+    protected final AuthConfig authConfig;
 
     protected ExternalIdNotesLoader(
         ExternalIdCache externalIdCache,
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory) {
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
       this.externalIdCache = externalIdCache;
       this.metricMaker = metricMaker;
       this.allUsersName = allUsersName;
       this.upsertPreprocessors = upsertPreprocessors;
       this.externalIdFactory = externalIdFactory;
+      this.authConfig = authConfig;
     }
 
     /**
@@ -162,17 +167,6 @@
         cacheUpdate.execute(updates);
       }
 
-      // Perform the cache update.
-      if (!externalIdNotes.noCacheUpdate) {
-        // Regardless of noCacheUpdate it's still possible that the ExternalIdCache instance is of
-        // type DisabledExternalIdCache, making this call a no-op.
-        externalIdCache.onReplace(
-            externalIdNotes.oldRev,
-            externalIdNotes.getRevision(),
-            updates.getRemoved(),
-            updates.getAdded());
-      }
-
       // Reindex accounts (if the subclass implements reindexAccount()).
       if (!externalIdNotes.noReindex) {
         Streams.concat(updates.getAdded().stream(), updates.getRemoved().stream())
@@ -203,8 +197,15 @@
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory) {
-      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors, externalIdFactory);
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
+      super(
+          externalIdCache,
+          metricMaker,
+          allUsersName,
+          upsertPreprocessors,
+          externalIdFactory,
+          authConfig);
       this.accountIndexer = accountIndexer;
     }
 
@@ -212,7 +213,12 @@
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .load();
     }
 
@@ -220,7 +226,12 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .load(rev);
     }
 
@@ -239,15 +250,27 @@
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory) {
-      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors, externalIdFactory);
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
+      super(
+          externalIdCache,
+          metricMaker,
+          allUsersName,
+          upsertPreprocessors,
+          externalIdFactory,
+          authConfig);
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .setNoReindex()
           .load();
     }
@@ -256,7 +279,12 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .setNoReindex()
           .load(rev);
     }
@@ -281,23 +309,24 @@
       AllUsersName allUsersName,
       Repository allUsersRepo,
       @Nullable ObjectId rev,
-      ExternalIdFactory externalIdFactory)
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
             new DisabledMetricMaker(),
             allUsersName,
             allUsersRepo,
             DynamicMap.emptyMap(),
-            externalIdFactory)
+            externalIdFactory,
+            isUserNameCaseInsensitiveMigrationMode)
         .setReadOnly()
-        .setNoCacheUpdate()
         .setNoReindex()
         .load(rev);
   }
 
   /**
-   * Loads the external ID notes for updates without cache evictions. The external ID notes are
-   * loaded from the current tip of the {@code refs/meta/external-ids} branch.
+   * Loads the external ID notes for updates. The external ID notes are loaded from the current tip
+   * of the {@code refs/meta/external-ids} branch.
    *
    * <p>Use this only from init, schema upgrades and tests.
    *
@@ -305,16 +334,19 @@
    *
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
-  public static ExternalIdNotes loadNoCacheUpdate(
-      AllUsersName allUsersName, Repository allUsersRepo, ExternalIdFactory externalIdFactory)
+  public static ExternalIdNotes load(
+      AllUsersName allUsersName,
+      Repository allUsersRepo,
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
             new DisabledMetricMaker(),
             allUsersName,
             allUsersRepo,
             DynamicMap.emptyMap(),
-            externalIdFactory)
-        .setNoCacheUpdate()
+            externalIdFactory,
+            isUserNameCaseInsensitiveMigrationMode)
         .setNoReindex()
         .load();
   }
@@ -348,15 +380,28 @@
 
   private Runnable afterReadRevision;
   private boolean readOnly = false;
-  private boolean noCacheUpdate = false;
   private boolean noReindex = false;
+  private boolean isUserNameCaseInsensitiveMigrationMode = false;
+  protected final Function<ExternalId, ObjectId> defaultNoteIdResolver =
+      (extId) -> {
+        ObjectId noteId = extId.key().sha1();
+        try {
+          if (isUserNameCaseInsensitiveMigrationMode && !noteMap.contains(noteId)) {
+            noteId = extId.key().caseSensitiveSha1();
+          }
+        } catch (IOException e) {
+          return noteId;
+        }
+        return noteId;
+      };
 
   private ExternalIdNotes(
       MetricMaker metricMaker,
       AllUsersName allUsersName,
       Repository allUsersRepo,
       DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode) {
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
@@ -378,6 +423,7 @@
             .addTarget(ExternalIdNotes.class)
             .build();
     this.externalIdFactory = externalIdFactory;
+    this.isUserNameCaseInsensitiveMigrationMode = isUserNameCaseInsensitiveMigrationMode;
   }
 
   public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
@@ -390,11 +436,6 @@
     return this;
   }
 
-  private ExternalIdNotes setNoCacheUpdate() {
-    noCacheUpdate = true;
-    return this;
-  }
-
   private ExternalIdNotes setNoReindex() {
     noReindex = true;
     return this;
@@ -449,16 +490,26 @@
    */
   public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
     checkLoaded();
+    ObjectId noteId = getNoteId(key);
+    if (noteMap.contains(noteId)) {
+
+      try (RevWalk rw = new RevWalk(repo)) {
+        ObjectId noteDataId = noteMap.get(noteId);
+        byte[] raw = readNoteData(rw, noteDataId);
+        return Optional.of(externalIdFactory.parse(noteId.name(), raw, noteDataId));
+      }
+    }
+    return Optional.empty();
+  }
+
+  protected ObjectId getNoteId(ExternalId.Key key) throws IOException {
     ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return Optional.empty();
+
+    if (!noteMap.contains(noteId) && isUserNameCaseInsensitiveMigrationMode) {
+      noteId = key.caseSensitiveSha1();
     }
 
-    try (RevWalk rw = new RevWalk(repo)) {
-      ObjectId noteDataId = noteMap.get(noteId);
-      byte[] raw = readNoteData(rw, noteDataId);
-      return Optional.of(externalIdFactory.parse(noteId.name(), raw, noteDataId));
-    }
+    return noteId;
   }
 
   /**
@@ -651,6 +702,12 @@
     cacheUpdates.add(cu -> cu.remove(removedExtIds));
   }
 
+  public void replace(
+      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    replace(accountId, toDelete, toAdd, defaultNoteIdResolver);
+  }
+
   /**
    * Replaces external IDs for an account by external ID keys.
    *
@@ -663,7 +720,10 @@
    *     the specified account.
    */
   public void replace(
-      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      Account.Id accountId,
+      Collection<ExternalId.Key> toDelete,
+      Collection<ExternalId> toAdd,
+      Function<ExternalId, ObjectId> noteIdResolver)
       throws IOException, DuplicateExternalIdKeyException {
     checkLoaded();
     checkSameAccount(toAdd, accountId);
@@ -681,7 +741,7 @@
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId, noteIdResolver);
             preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
@@ -757,6 +817,32 @@
     replace(accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd);
   }
 
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(
+      Collection<ExternalId> toDelete,
+      Collection<ExternalId> toAdd,
+      Function<ExternalId, ObjectId> noteIdResolver)
+      throws IOException, DuplicateExternalIdKeyException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(
+        accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd, noteIdResolver);
+  }
+
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
@@ -865,9 +951,26 @@
    */
   private ExternalId upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
+    return upsert(rw, ins, noteMap, extId, defaultNoteIdResolver);
+  }
+
+  /**
+   * Inserts or updates a new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists, it is overwritten.
+   */
+  private ExternalId upsert(
+      RevWalk rw,
+      ObjectInserter ins,
+      NoteMap noteMap,
+      ExternalId extId,
+      Function<ExternalId, ObjectId> noteIdResolver)
+      throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     Config c = new Config();
-    if (noteMap.contains(noteId)) {
+    ObjectId resolvedNoteId = noteIdResolver.apply(extId);
+    if (noteMap.contains(resolvedNoteId)) {
+      noteId = resolvedNoteId;
       ObjectId noteDataId = noteMap.get(noteId);
       byte[] raw = readNoteData(rw, noteDataId);
       try {
@@ -892,7 +995,8 @@
    */
   private void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
-    ObjectId noteId = extId.key().sha1();
+    ObjectId noteId = getNoteId(extId.key());
+
     if (!noteMap.contains(noteId)) {
       return;
     }
@@ -916,10 +1020,12 @@
    * @return the external ID that was removed, {@code null} if no external ID with the specified key
    *     exists
    */
+  @Nullable
   private ExternalId remove(
       RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
-    ObjectId noteId = extIdKey.sha1();
+    ObjectId noteId = getNoteId(extIdKey);
+
     if (!noteMap.contains(noteId)) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index 0d715ae..fb7f6c4 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -70,13 +71,15 @@
   private final Timer0 readAllLatency;
   private final Timer0 readSingleLatency;
   private final ExternalIdFactory externalIdFactory;
+  private final AuthConfig authConfig;
 
   @Inject
   ExternalIdReader(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       MetricMaker metricMaker,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactory externalIdFactory,
+      AuthConfig authConfig) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.readAllLatency =
@@ -92,6 +95,7 @@
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS));
     this.externalIdFactory = externalIdFactory;
+    this.authConfig = authConfig;
   }
 
   @VisibleForTesting
@@ -99,6 +103,12 @@
     this.failOnLoad = failOnLoad;
   }
 
+  public void checkReadEnabled() throws IOException {
+    if (failOnLoad) {
+      throw new IOException("Reading from external IDs is disabled");
+    }
+  }
+
   ObjectId readRevision() throws IOException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
       return readRevision(repo);
@@ -111,7 +121,13 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null, externalIdFactory).all();
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              null,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .all();
     }
   }
 
@@ -130,7 +146,13 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev, externalIdFactory).all();
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              rev,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .all();
     }
   }
 
@@ -140,7 +162,13 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null, externalIdFactory).get(key);
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              null,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .get(key);
     }
   }
 
@@ -151,13 +179,13 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev, externalIdFactory).get(key);
-    }
-  }
-
-  private void checkReadEnabled() throws IOException {
-    if (failOnLoad) {
-      throw new IOException("Reading from external IDs is disabled");
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              rev,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .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 4e1e524..9450ff5 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -35,11 +36,19 @@
 public class ExternalIds {
   private final ExternalIdReader externalIdReader;
   private final ExternalIdCache externalIdCache;
+  private final AuthConfig authConfig;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
-  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+  public ExternalIds(
+      ExternalIdReader externalIdReader,
+      ExternalIdCache externalIdCache,
+      ExternalIdKeyFactory externalIdKeyFactory,
+      AuthConfig authConfig) {
     this.externalIdReader = externalIdReader;
     this.externalIdCache = externalIdCache;
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
   }
 
   /** Returns all external IDs. */
@@ -54,7 +63,15 @@
 
   /** Returns the specified external ID. */
   public Optional<ExternalId> get(ExternalId.Key key) throws IOException {
-    return externalIdCache.byKey(key);
+    Optional<ExternalId> externalId = Optional.empty();
+    if (authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+      externalId =
+          externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false));
+    }
+    if (!externalId.isPresent()) {
+      externalId = externalIdCache.byKey(key);
+    }
+    return externalId;
   }
 
   /** Returns the specified external ID from the given revision. */
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index cf0e5d3..4e67e3d 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -61,14 +61,14 @@
 
   public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null, externalIdFactory));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null, externalIdFactory, false));
     }
   }
 
   public List<ConsistencyProblemInfo> check(ObjectId rev)
       throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev, externalIdFactory));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev, externalIdFactory, false));
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java
new file mode 100644
index 0000000..8a3e4f1
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface OnlineExternalIdCaseSensivityMigratiorExecutor {}
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
new file mode 100644
index 0000000..72e7e90
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class OnlineExternalIdCaseSensivityMigrator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private Executor executor;
+  private ExternalIdCaseSensitivityMigrator.Factory migratorFactory;
+  private ExternalIds externalIds;
+  private VersionManager versionManager;
+  private Config globalConfig;
+  private Path sitePath;
+  private final TextProgressMonitor monitor = new TextProgressMonitor();
+  private boolean isUserNameCaseInsensitive;
+  private boolean isUserNameCaseInsensitiveMigrationMode;
+
+  @Inject
+  public OnlineExternalIdCaseSensivityMigrator(
+      @OnlineExternalIdCaseSensivityMigratiorExecutor ExecutorService executor,
+      ExternalIdCaseSensitivityMigrator.Factory migratorFactory,
+      ExternalIds externalIds,
+      VersionManager versionManager,
+      @GerritServerConfig Config globalConfig,
+      @SitePath Path sitePath) {
+    this.migratorFactory = migratorFactory;
+    this.externalIds = externalIds;
+    this.versionManager = versionManager;
+    this.globalConfig = globalConfig;
+    this.sitePath = sitePath;
+    this.executor = executor;
+    this.isUserNameCaseInsensitiveMigrationMode =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
+    this.isUserNameCaseInsensitive =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitive", false);
+  }
+
+  public void migrate() {
+    if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
+      logger.atSevere().log(
+          "External IDs online migration requires auth.userNameCaseInsensitive and"
+              + " auth.userNameCaseInsensitiveMigrationMode to be set to true. Skipping"
+              + " migration!");
+      return;
+    }
+    executor.execute(
+        () -> {
+          try {
+            Collection<ExternalId> todo = externalIds.all();
+            try {
+              monitor.beginTask("Converting external ID note names", todo.size());
+              migratorFactory
+                  .create(isUserNameCaseInsensitive, false)
+                  .migrate(todo, () -> monitor.update(1));
+            } finally {
+              monitor.endTask();
+            }
+            try {
+              updateGerritConfig();
+              monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
+              versionManager.startReindexer("accounts", true);
+            } finally {
+              monitor.endTask();
+            }
+            logger.atInfo().log("External IDs migration completed!");
+          } catch (IOException | ConfigInvalidException e) {
+            logger.atSevere().withCause(e).log(
+                "Exception during the external ids migration, cause %s", e.getMessage());
+          } catch (ReindexerAlreadyRunningException e) {
+            logger.atSevere().log("Failed to reindex external ids: %s", e.getMessage());
+          }
+        });
+  }
+
+  private void updateGerritConfig() throws IOException, ConfigInvalidException {
+    logger.atInfo().log(
+        "Setting auth.userNameCaseInsensitiveMigrationMode to false in gerrit.config.");
+
+    FileBasedConfig config =
+        new FileBasedConfig(
+            globalConfig, sitePath.resolve("etc/gerrit.config").toFile(), FS.DETECTED);
+    config.load();
+    config.setBoolean("auth", null, "userNameCaseInsensitiveMigrationMode", false);
+
+    config.save();
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
index 33443c1..eb2bea9 100644
--- a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
+++ b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
@@ -20,6 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import java.util.Collection;
 
@@ -29,9 +30,12 @@
 
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
+  private AuthConfig authConfig;
+
   @Inject
-  public PasswordVerifier(ExternalIdKeyFactory externalIdKeyFactory) {
+  public PasswordVerifier(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
     this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
   }
 
   /** Returns {@code true} if there is an external ID matching both the username and password. */
@@ -40,13 +44,23 @@
     if (password == null) {
       return false;
     }
+
     for (ExternalId id : externalIds) {
       // Only process the "username:$USER" entry, which is unique.
-      if (!id.isScheme(SCHEME_USERNAME)
-          || !id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username))) {
+      if (!id.isScheme(SCHEME_USERNAME)) {
         continue;
       }
 
+      if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username))) {
+        if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+          continue;
+        }
+
+        if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username, false))) {
+          continue;
+        }
+      }
+
       String hashedStr = id.password();
       if (!Strings.isNullOrEmpty(hashedStr)) {
         try {
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index b23782f..828f868 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AgreementInput;
@@ -575,6 +576,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String generateHttpPassword() throws RestApiException {
     HttpPasswordInput input = new HttpPasswordInput();
@@ -589,6 +591,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String setHttpPassword(String password) throws RestApiException {
     HttpPasswordInput input = new HttpPasswordInput();
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a49061d..66a845a 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -19,8 +19,8 @@
 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.ApplyPatchPatchSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -54,6 +54,7 @@
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
@@ -65,13 +66,12 @@
 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.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.AddToAttentionSet;
+import com.google.gerrit.server.restapi.change.ApplyPatch;
 import com.google.gerrit.server.restapi.change.AttentionSet;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
 import com.google.gerrit.server.restapi.change.ChangeMessages;
@@ -88,7 +88,6 @@
 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;
@@ -102,6 +101,7 @@
 import com.google.gerrit.server.restapi.change.PutMessage;
 import com.google.gerrit.server.restapi.change.PutTopic;
 import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.RebaseChain;
 import com.google.gerrit.server.restapi.change.Restore;
 import com.google.gerrit.server.restapi.change.Revert;
 import com.google.gerrit.server.restapi.change.RevertSubmission;
@@ -111,7 +111,6 @@
 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.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -144,8 +143,10 @@
   private final RevertSubmission revertSubmission;
   private final Restore restore;
   private final CreateMergePatchSet updateByMerge;
+  private final ApplyPatch applyPatch;
   private final Provider<SubmittedTogether> submittedTogether;
   private final Rebase.CurrentRevision rebase;
+  private final RebaseChain rebaseChain;
   private final DeleteChange deleteChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
@@ -167,18 +168,15 @@
   private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
-  private final CheckSubmitRequirement checkSubmitRequirement;
+  private final Provider<CheckSubmitRequirement> checkSubmitRequirementProvider;
   private final Index index;
   private final Move move;
   private final PostPrivate postPrivate;
   private final DeletePrivate deletePrivate;
-  private final Ignore ignore;
-  private final Unignore unignore;
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
   private final Provider<GetPureRevert> getPureRevertProvider;
-  private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
   private final Injector injector;
   private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@@ -199,8 +197,10 @@
       RevertSubmission revertSubmission,
       Restore restore,
       CreateMergePatchSet updateByMerge,
+      ApplyPatch applyPatch,
       Provider<SubmittedTogether> submittedTogether,
       Rebase.CurrentRevision rebase,
+      RebaseChain rebaseChain,
       DeleteChange deleteChange,
       GetTopic getTopic,
       PutTopic putTopic,
@@ -222,18 +222,15 @@
       Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
-      CheckSubmitRequirement checkSubmitRequirement,
+      Provider<CheckSubmitRequirement> checkSubmitRequirement,
       Index index,
       Move move,
       PostPrivate postPrivate,
       DeletePrivate deletePrivate,
-      Ignore ignore,
-      Unignore unignore,
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
       Provider<GetPureRevert> getPureRevertProvider,
-      StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
       @Assisted ChangeResource change,
       Injector injector,
@@ -252,8 +249,10 @@
     this.abandon = abandon;
     this.restore = restore;
     this.updateByMerge = updateByMerge;
+    this.applyPatch = applyPatch;
     this.submittedTogether = submittedTogether;
     this.rebase = rebase;
+    this.rebaseChain = rebaseChain;
     this.deleteChange = deleteChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
@@ -275,18 +274,15 @@
     this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
-    this.checkSubmitRequirement = checkSubmitRequirement;
+    this.checkSubmitRequirementProvider = checkSubmitRequirement;
     this.index = index;
     this.move = move;
     this.postPrivate = postPrivate;
     this.deletePrivate = deletePrivate;
-    this.ignore = ignore;
-    this.unignore = unignore;
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
     this.getPureRevertProvider = getPureRevertProvider;
-    this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
     this.injector = injector;
@@ -403,6 +399,15 @@
   }
 
   @Override
+  public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
+    try {
+      return applyPatch.apply(change, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply patch", e);
+    }
+  }
+
+  @Override
   public SubmittedTogetherInfo submittedTogether(
       EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
       throws RestApiException {
@@ -427,6 +432,15 @@
   }
 
   @Override
+  public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+    try {
+      return rebaseChain.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase chain", e);
+    }
+  }
+
+  @Override
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
@@ -597,6 +611,7 @@
     }
   }
 
+  @Nullable
   @Override
   public AccountInfo getAssignee() throws RestApiException {
     try {
@@ -616,6 +631,7 @@
     }
   }
 
+  @Nullable
   @Override
   public AccountInfo deleteAssignee() throws RestApiException {
     try {
@@ -714,10 +730,27 @@
   }
 
   @Override
+  public CheckSubmitRequirementRequest checkSubmitRequirementRequest() {
+    return new CheckSubmitRequirementRequest() {
+      @Override
+      public SubmitRequirementResultInfo get() throws RestApiException {
+        try {
+          CheckSubmitRequirement check = checkSubmitRequirementProvider.get();
+          check.setSrName(this.srName());
+          check.setRefsConfigChangeId(this.getRefsConfigChangeId());
+          return check.apply(change, null).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot check submit requirement", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException {
     try {
-      return checkSubmitRequirement.apply(change, input).value();
+      return checkSubmitRequirementProvider.get().apply(change, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check submit requirement", e);
     }
@@ -733,30 +766,6 @@
   }
 
   @Override
-  public void ignore(boolean ignore) throws RestApiException {
-    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-    // StarredChangesUtil.
-    try {
-      if (ignore) {
-        this.ignore.apply(change, new Input());
-      } else {
-        unignore.apply(change, new Input());
-      }
-    } catch (StorageException | IllegalLabelException e) {
-      throw asRestApiException("Cannot ignore change", e);
-    }
-  }
-
-  @Override
-  public boolean ignored() throws RestApiException {
-    try {
-      return stars.isIgnored(change);
-    } catch (StorageException e) {
-      throw asRestApiException("Cannot check if ignored", e);
-    }
-  }
-
-  @Override
   public PureRevertInfo pureRevert() throws RestApiException {
     return pureRevert(null);
   }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 764c46d..a7931f1 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.api.changes.DraftApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -40,6 +41,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -64,7 +66,8 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.restapi.change.ApplyFix;
+import com.google.gerrit.server.restapi.change.ApplyProvidedFix;
+import com.google.gerrit.server.restapi.change.ApplyStoredFix;
 import com.google.gerrit.server.restapi.change.CherryPick;
 import com.google.gerrit.server.restapi.change.Comments;
 import com.google.gerrit.server.restapi.change.CreateDraftComment;
@@ -74,7 +77,6 @@
 import com.google.gerrit.server.restapi.change.GetArchive;
 import com.google.gerrit.server.restapi.change.GetCommit;
 import com.google.gerrit.server.restapi.change.GetDescription;
-import com.google.gerrit.server.restapi.change.GetFixPreview;
 import com.google.gerrit.server.restapi.change.GetMergeList;
 import com.google.gerrit.server.restapi.change.GetPatch;
 import com.google.gerrit.server.restapi.change.GetRelated;
@@ -86,7 +88,7 @@
 import com.google.gerrit.server.restapi.change.ListRobotComments;
 import com.google.gerrit.server.restapi.change.Mergeable;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.restapi.change.PreviewSubmit;
+import com.google.gerrit.server.restapi.change.PreviewFix;
 import com.google.gerrit.server.restapi.change.PutDescription;
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Reviewed;
@@ -119,7 +121,6 @@
   private final Rebase rebase;
   private final RebaseUtil rebaseUtil;
   private final Submit submit;
-  private final PreviewSubmit submitPreview;
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
   private final RevisionResource revision;
@@ -134,8 +135,10 @@
   private final ListRobotComments listRobotComments;
   private final ListPortedComments listPortedComments;
   private final ListPortedDrafts listPortedDrafts;
-  private final ApplyFix applyFix;
-  private final GetFixPreview getFixPreview;
+  private final ApplyStoredFix applyStoredFix;
+  private final PreviewFix.Stored previewStoredFix;
+  private final ApplyProvidedFix applyProvidedFix;
+  private final PreviewFix.Provided previewProvidedFix;
   private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
@@ -167,7 +170,6 @@
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
-      PreviewSubmit submitPreview,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
       Files files,
@@ -181,8 +183,10 @@
       ListRobotComments listRobotComments,
       ListPortedComments listPortedComments,
       ListPortedDrafts listPortedDrafts,
-      ApplyFix applyFix,
-      GetFixPreview getFixPreview,
+      ApplyStoredFix applyStoredFix,
+      PreviewFix.Stored previewStoredFix,
+      ApplyProvidedFix applyProvidedFix,
+      PreviewFix.Provided previewProvidedFix,
       Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
@@ -213,7 +217,6 @@
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
-    this.submitPreview = submitPreview;
     this.files = files;
     this.putReviewed = putReviewed;
     this.deleteReviewed = deleteReviewed;
@@ -227,8 +230,10 @@
     this.listRobotComments = listRobotComments;
     this.listPortedComments = listPortedComments;
     this.listPortedDrafts = listPortedDrafts;
-    this.applyFix = applyFix;
-    this.getFixPreview = getFixPreview;
+    this.applyStoredFix = applyStoredFix;
+    this.previewStoredFix = previewStoredFix;
+    this.applyProvidedFix = applyProvidedFix;
+    this.previewProvidedFix = previewProvidedFix;
     this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
@@ -270,16 +275,6 @@
   }
 
   @Override
-  public BinaryResult submitPreview(String format) throws RestApiException {
-    try {
-      submitPreview.setFormat(format);
-      return submitPreview.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get submit preview", e);
-    }
-  }
-
-  @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebaseAsInfo(in)._number);
@@ -491,7 +486,16 @@
   @Override
   public EditInfo applyFix(String fixId) throws RestApiException {
     try {
-      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+      return applyStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply stored fix", e);
+    }
+  }
+
+  @Override
+  public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+    try {
+      return applyProvidedFix.apply(revision, applyProvidedFixInput).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot apply fix", e);
     }
@@ -500,9 +504,19 @@
   @Override
   public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
     try {
-      return getFixPreview.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
+      return previewStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get fix preview", e);
+      throw asRestApiException("Cannot preview stored fix", e);
+    }
+  }
+
+  @Override
+  public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException {
+    try {
+      return previewProvidedFix.apply(revision, applyProvidedFixInput).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot preview provided fix", e);
     }
   }
 
@@ -629,7 +643,13 @@
 
   @Override
   public RelatedChangesInfo related() throws RestApiException {
+    return related(EnumSet.noneOf(GetRelatedOption.class));
+  }
+
+  @Override
+  public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
     try {
+      options.forEach(getRelated::addOption);
       return getRelated.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get related changes", e);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 6d7fc15..3a892bc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -48,6 +49,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -63,6 +65,7 @@
 import com.google.gerrit.server.restapi.project.CheckAccess;
 import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.server.restapi.project.CommitsIncludedInRefs;
 import com.google.gerrit.server.restapi.project.CreateAccessChange;
 import com.google.gerrit.server.restapi.project.CreateProject;
 import com.google.gerrit.server.restapi.project.DeleteBranches;
@@ -77,6 +80,7 @@
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListLabels;
+import com.google.gerrit.server.restapi.project.ListSubmitRequirements;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -88,8 +92,11 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public class ProjectApiImpl implements ProjectApi {
   interface Factory {
@@ -116,6 +123,7 @@
   private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
+  private final CommitsIncludedInRefs commitsIncludedInRefs;
   private final Provider<ListBranches> listBranches;
   private final Provider<ListTags> listTags;
   private final DeleteBranches deleteBranches;
@@ -133,8 +141,10 @@
   private final Index index;
   private final IndexChanges indexChanges;
   private final Provider<ListLabels> listLabels;
+  private final Provider<ListSubmitRequirements> listSubmitRequirements;
   private final PostLabels postLabels;
   private final LabelApiImpl.Factory labelApi;
+  private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -154,6 +164,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -171,8 +182,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -191,6 +204,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        commitsIncludedInRefs,
         listBranches,
         listTags,
         deleteBranches,
@@ -209,8 +223,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         null);
   }
 
@@ -232,6 +248,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -249,8 +266,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -269,6 +288,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        commitsIncludedInRefs,
         listBranches,
         listTags,
         deleteBranches,
@@ -287,8 +307,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         name);
   }
 
@@ -309,6 +331,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -327,8 +350,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -346,6 +371,7 @@
     this.setAccess = setAccess;
     this.getConfig = getConfig;
     this.putConfig = putConfig;
+    this.commitsIncludedInRefs = commitsIncludedInRefs;
     this.listBranches = listBranches;
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
@@ -365,8 +391,10 @@
     this.index = index;
     this.indexChanges = indexChanges;
     this.listLabels = listLabels;
+    this.listSubmitRequirements = listSubmitRequirements;
     this.postLabels = postLabels;
     this.labelApi = labelApi;
+    this.submitRequirementApi = submitRequirementApi;
   }
 
   @Override
@@ -483,8 +511,20 @@
   }
 
   @Override
+  public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+      throws RestApiException {
+    try {
+      commitsIncludedInRefs.addCommits(commits);
+      commitsIncludedInRefs.addRefs(refs);
+      return commitsIncludedInRefs.apply(project).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list commits included in refs", e);
+    }
+  }
+
+  @Override
   public ListRefsRequest<BranchInfo> branches() {
-    return new ListRefsRequest<BranchInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<BranchInfo> get() throws RestApiException {
         try {
@@ -498,7 +538,7 @@
 
   @Override
   public ListRefsRequest<TagInfo> tags() {
-    return new ListRefsRequest<TagInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<TagInfo> get() throws RestApiException {
         try {
@@ -714,6 +754,20 @@
   }
 
   @Override
+  public ListSubmitRequirementsRequest submitRequirements() {
+    return new ListSubmitRequirementsRequest() {
+      @Override
+      public List<SubmitRequirementInfo> get() throws RestApiException {
+        try {
+          return listSubmitRequirements.get().withInherited(inherited).apply(checkExists()).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list submit requirements", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public LabelApi label(String labelName) throws RestApiException {
     try {
       return labelApi.create(checkExists(), labelName);
@@ -723,6 +777,15 @@
   }
 
   @Override
+  public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+    try {
+      return submitRequirementApi.create(checkExists(), name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse submit requirement", e);
+    }
+  }
+
+  @Override
   public void labels(BatchLabelInput input) throws RestApiException {
     try {
       postLabels.apply(checkExists(), input);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
new file mode 100644
index 0000000..8ed1175
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import com.google.gerrit.server.query.project.ProjectQueryBuilder;
+import com.google.gerrit.server.query.project.ProjectQueryBuilderImpl;
+import com.google.inject.AbstractModule;
+
+public class ProjectQueryBuilderModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ProjectQueryBuilder.class).to(ProjectQueryBuilderImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsModule.java b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
index 987c71f..9f7e1b4 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsModule.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
@@ -29,5 +29,6 @@
     factory(CommitApiImpl.Factory.class);
     factory(DashboardApiImpl.Factory.class);
     factory(LabelApiImpl.Factory.class);
+    factory(SubmitRequirementApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
new file mode 100644
index 0000000..aa6ef71
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+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.SubmitRequirementResource;
+import com.google.gerrit.server.restapi.project.CreateSubmitRequirement;
+import com.google.gerrit.server.restapi.project.DeleteSubmitRequirement;
+import com.google.gerrit.server.restapi.project.GetSubmitRequirement;
+import com.google.gerrit.server.restapi.project.SubmitRequirementsCollection;
+import com.google.gerrit.server.restapi.project.UpdateSubmitRequirement;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SubmitRequirementApiImpl implements SubmitRequirementApi {
+  interface Factory {
+    SubmitRequirementApiImpl create(ProjectResource project, String name);
+  }
+
+  private final SubmitRequirementsCollection submitRequirements;
+  private final CreateSubmitRequirement createSubmitRequirement;
+  private final UpdateSubmitRequirement updateSubmitRequirement;
+  private final DeleteSubmitRequirement deleteSubmitRequirement;
+  private final GetSubmitRequirement getSubmitRequirement;
+  private final String name;
+  private final ProjectCache projectCache;
+
+  private ProjectResource project;
+
+  @Inject
+  SubmitRequirementApiImpl(
+      SubmitRequirementsCollection submitRequirements,
+      CreateSubmitRequirement createSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement,
+      DeleteSubmitRequirement deleteSubmitRequirement,
+      GetSubmitRequirement getSubmitRequirement,
+      ProjectCache projectCache,
+      @Assisted ProjectResource project,
+      @Assisted String name) {
+    this.submitRequirements = submitRequirements;
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+    this.deleteSubmitRequirement = deleteSubmitRequirement;
+    this.getSubmitRequirement = getSubmitRequirement;
+    this.projectCache = projectCache;
+    this.project = project;
+    this.name = name;
+  }
+
+  @Override
+  public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+    try {
+      createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
+
+      // recreate project resource because project state was updated
+      project =
+          new ProjectResource(
+              projectCache
+                  .get(project.getNameKey())
+                  .orElseThrow(illegalState(project.getNameKey())),
+              project.getUser());
+
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo get() throws RestApiException {
+    try {
+      return getSubmitRequirement.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+    try {
+      return updateSubmitRequirement.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirement", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteSubmitRequirement.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete submit requirement", e);
+    }
+  }
+
+  private SubmitRequirementResource resource() throws RestApiException, PermissionBackendException {
+    return submitRequirements.parse(project, IdString.fromDecoded(name));
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCache.java b/java/com/google/gerrit/server/approval/ApprovalCache.java
deleted file mode 100644
index 5637249..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalCache.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.approval;
-
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.server.notedb.ChangeNotes;
-
-/**
- * Cache that holds approvals per patch set and NoteDb state. This includes approvals copied forward
- * from older patch sets.
- */
-public interface ApprovalCache {
-  /** Returns {@link PatchSetApproval}s for the given patch set. */
-  Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId);
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
deleted file mode 100644
index fd31da9..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.approval;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.proto.Cache;
-import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-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.util.concurrent.ExecutionException;
-
-/** Implementation of the {@link ApprovalCache} interface */
-public class ApprovalCacheImpl implements ApprovalCache {
-  private static final String CACHE_NAME = "approvals";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        bind(ApprovalCache.class).to(ApprovalCacheImpl.class);
-        persist(
-                CACHE_NAME,
-                Cache.PatchSetApprovalsKeyProto.class,
-                Cache.AllPatchSetApprovalsProto.class)
-            .version(2)
-            .loader(Loader.class)
-            .keySerializer(new ProtobufSerializer<>(Cache.PatchSetApprovalsKeyProto.parser()))
-            .valueSerializer(new ProtobufSerializer<>(Cache.AllPatchSetApprovalsProto.parser()));
-      }
-    };
-  }
-
-  private final LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto>
-      cache;
-
-  @Inject
-  ApprovalCacheImpl(
-      @Named(CACHE_NAME)
-          LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> cache) {
-    this.cache = cache;
-  }
-
-  @Override
-  public Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId) {
-    try {
-      return fromProto(
-          cache.get(
-              Cache.PatchSetApprovalsKeyProto.newBuilder()
-                  .setChangeId(notes.getChangeId().get())
-                  .setPatchSetId(psId.get())
-                  .setProject(notes.getProjectName().get())
-                  .setId(
-                      ByteString.copyFrom(
-                          ObjectIdCacheSerializer.INSTANCE.serialize(notes.getMetaId())))
-                  .build()));
-    } catch (ExecutionException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  @Singleton
-  static class Loader
-      extends CacheLoader<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> {
-    private final ApprovalInference approvalInference;
-    private final ChangeNotes.Factory changeNotesFactory;
-
-    @Inject
-    Loader(ApprovalInference approvalInference, ChangeNotes.Factory changeNotesFactory) {
-      this.approvalInference = approvalInference;
-      this.changeNotesFactory = changeNotesFactory;
-    }
-
-    @Override
-    public Cache.AllPatchSetApprovalsProto load(Cache.PatchSetApprovalsKeyProto key)
-        throws Exception {
-      Change.Id changeId = Change.id(key.getChangeId());
-      return toProto(
-          approvalInference.forPatchSet(
-              changeNotesFactory.createChecked(
-                  Project.nameKey(key.getProject()),
-                  changeId,
-                  ObjectIdCacheSerializer.INSTANCE.deserialize(key.getId().toByteArray())),
-              PatchSet.id(changeId, key.getPatchSetId()),
-              null
-              /* revWalk= */ ,
-              null
-              /* repoConfig= */ ));
-    }
-  }
-
-  private static Iterable<PatchSetApproval> fromProto(Cache.AllPatchSetApprovalsProto proto) {
-    ImmutableList.Builder<PatchSetApproval> builder = ImmutableList.builder();
-    for (Entities.PatchSetApproval psa : proto.getApprovalList()) {
-      builder.add(PatchSetApprovalProtoConverter.INSTANCE.fromProto(psa));
-    }
-    return builder.build();
-  }
-
-  private static Cache.AllPatchSetApprovalsProto toProto(Iterable<PatchSetApproval> autoValue) {
-    Cache.AllPatchSetApprovalsProto.Builder builder = Cache.AllPatchSetApprovalsProto.newBuilder();
-    for (PatchSetApproval psa : autoValue) {
-      builder.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(psa));
-    }
-    return builder.build();
-  }
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
new file mode 100644
index 0000000..a1889da
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -0,0 +1,583 @@
+// 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.approval;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.LabelNormalizer;
+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.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.approval.ApprovalContext;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Computes copied approvals for a given patch set.
+ *
+ * <p>Approvals are copied if:
+ *
+ * <ul>
+ *   <li>the approval on the previous patch set matches the copy condition of its label
+ *   <li>the approval is not overridden by a current approval on the patch set
+ * </ul>
+ *
+ * <p>Callers should store the copied approvals in NoteDb when a new patch set is created.
+ */
+@Singleton
+@VisibleForTesting
+public class ApprovalCopier {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @AutoValue
+  public abstract static class Result {
+    /**
+     * Approvals that have been copied from the previous patch set.
+     *
+     * <p>An approval is copied if:
+     *
+     * <ul>
+     *   <li>the approval on the previous patch set matches the copy condition of its label
+     *   <li>the approval is not overridden by a current approval on the patch set
+     * </ul>
+     */
+    public abstract ImmutableSet<PatchSetApprovalData> copiedApprovals();
+
+    /**
+     * Approvals on the previous patch set that have not been copied to the patch set.
+     *
+     * <p>These approvals didn't match the copy condition of their labels and hence haven't been
+     * copied.
+     *
+     * <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
+     * sets that were outdated before are not included.
+     */
+    public abstract ImmutableSet<PatchSetApprovalData> outdatedApprovals();
+
+    static Result empty() {
+      return create(
+          /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    }
+
+    @VisibleForTesting
+    public static Result create(
+        ImmutableSet<PatchSetApprovalData> copiedApprovals,
+        ImmutableSet<PatchSetApprovalData> outdatedApprovals) {
+      return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
+    }
+
+    /**
+     * A {@link PatchSetApproval} with information about which atoms of the copy condition are
+     * passing/failing.
+     */
+    @AutoValue
+    public abstract static class PatchSetApprovalData {
+      /** The approval. */
+      public abstract PatchSetApproval patchSetApproval();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are fulfilled.
+       *
+       * <p>Example: The expression
+       *
+       * <pre>
+       * changekind:TRIVIAL_REBASE OR is:MIN
+       * </pre>
+       *
+       * has two leaf predicates:
+       *
+       * <ul>
+       *   <li>changekind:TRIVIAL_REBASE
+       *   <li>is:MIN
+       * </ul>
+       *
+       * This method will return the leaf predicates that are fulfilled, for example if only the
+       * first predicate is fulfilled, the returned list will be equal to
+       * ["changekind:TRIVIAL_REBASE"].
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> passingAtoms();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+       * #passingAtoms()} for more details.
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> failingAtoms();
+
+      @VisibleForTesting
+      public static PatchSetApprovalData create(
+          PatchSetApproval approval,
+          ImmutableSet<String> passingAtoms,
+          ImmutableSet<String> failingAtoms) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, passingAtoms, failingAtoms);
+      }
+
+      private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, ImmutableSet.of(), ImmutableSet.of());
+      }
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+  private final ChangeKindCache changeKindCache;
+  private final PatchSetUtil psUtil;
+  private final LabelNormalizer labelNormalizer;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final OneOffRequestContext requestContext;
+
+  @Inject
+  ApprovalCopier(
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      ChangeKindCache changeKindCache,
+      PatchSetUtil psUtil,
+      LabelNormalizer labelNormalizer,
+      ApprovalQueryBuilder approvalQueryBuilder,
+      OneOffRequestContext requestContext) {
+    this.repoManager = repoManager;
+    this.projectCache = projectCache;
+    this.changeKindCache = changeKindCache;
+    this.psUtil = psUtil;
+    this.labelNormalizer = labelNormalizer;
+    this.approvalQueryBuilder = approvalQueryBuilder;
+    this.requestContext = requestContext;
+  }
+
+  /**
+   * Returns all copied approvals that apply to the given patch set.
+   *
+   * <p>Approvals are copied if:
+   *
+   * <ul>
+   *   <li>the approval on the previous patch set matches the copy condition of its label
+   *   <li>the approval is not overridden by a current approval on the patch set
+   * </ul>
+   */
+  @VisibleForTesting
+  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
+    ProjectState project;
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Computing labels for patch set",
+            Metadata.builder()
+                .changeId(notes.load().getChangeId().get())
+                .patchSetId(ps.id().get())
+                .build())) {
+      project =
+          projectCache
+              .get(notes.getProjectName())
+              .orElseThrow(illegalState(notes.getProjectName()));
+      return computeForPatchSet(project.getLabelTypes(), notes, ps, rw, repoConfig);
+    }
+  }
+
+  /**
+   * Returns all follow-up patch sets of the given patch set to which the given approval is
+   * copyable.
+   *
+   * <p>An approval is considered as copyable to a follow-up patch set if it matches the copy rules
+   * of the label and it is copyable to all intermediate follow-up patch sets as well.
+   *
+   * <p>The returned follow-up patch sets are returned in the order of their patch set IDs.
+   *
+   * <p>Note: This method only checks the copy rules to detect if the approval is copyable. There
+   * are other factors, not checked here, that can prevent the copying of the approval to the
+   * returned follow-up patch sets (e.g. if they already have a matching non-copy approval that
+   * prevents the copying).
+   *
+   * @param changeNotes the change notes
+   * @param sourcePatchSet the patch set on which the approval was applied
+   * @param approverId the account ID of the user that applied the approval
+   * @param label the label of the approval that was applied
+   * @param approvalValue the value of the approval that was applied
+   * @return the follow-up patch sets to which the approval is copyable, ordered by patch set ID
+   */
+  public ImmutableList<PatchSet.Id> forApproval(
+      ChangeNotes changeNotes,
+      PatchSet sourcePatchSet,
+      Account.Id approverId,
+      String label,
+      short approvalValue)
+      throws IOException {
+    ImmutableList.Builder<PatchSet.Id> targetPatchSetsBuilder = ImmutableList.builder();
+
+    Optional<LabelType> labelType =
+        projectCache
+            .get(changeNotes.getProjectName())
+            .orElseThrow(illegalState(changeNotes.getProjectName()))
+            .getLabelTypes()
+            .byLabel(label);
+    if (!labelType.isPresent()) {
+      // no label type exists for this label, hence this approval cannot be copied
+      return ImmutableList.of();
+    }
+
+    try (Repository repo = repoManager.openRepository(changeNotes.getProjectName());
+        RevWalk revWalk = new RevWalk(repo)) {
+      ImmutableList<PatchSet.Id> followUpPatchSets =
+          changeNotes.getPatchSets().keySet().stream()
+              .filter(psId -> psId.get() > sourcePatchSet.id().get())
+              .collect(toImmutableList());
+      PatchSet priorPatchSet = sourcePatchSet;
+
+      // Iterate over the follow-up patch sets in order to copy the approval from their prior patch
+      // set if possible (copy from PS N-1 to PS N).
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        PatchSet followUpPatchSet = psUtil.get(changeNotes, followUpPatchSetId);
+        ChangeKind changeKind =
+            changeKindCache.getChangeKind(
+                changeNotes.getProjectName(),
+                revWalk,
+                repo.getConfig(),
+                priorPatchSet.commitId(),
+                followUpPatchSet.commitId());
+        boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
+
+        if (computeCopyResult(
+                changeNotes,
+                priorPatchSet.id(),
+                followUpPatchSet,
+                approverId,
+                labelType.get(),
+                approvalValue,
+                changeKind,
+                isMerge,
+                revWalk,
+                repo.getConfig())
+            .canCopy()) {
+          targetPatchSetsBuilder.add(followUpPatchSetId);
+        } else {
+          // The approval is not copyable to this follow-up patch set.
+          // This means it's also not copyable to any further follow-up patch set and we should stop
+          // the loop here.
+          break;
+        }
+        priorPatchSet = followUpPatchSet;
+      }
+    }
+    return targetPatchSetsBuilder.build();
+  }
+
+  /**
+   * Checks whether a given approval can be copied from the given source patch set to the given
+   * target patch set.
+   *
+   * <p>The returned result also informs about which atoms of the copy condition are
+   * passing/failing.
+   */
+  private ApprovalCopyResult computeCopyResult(
+      ChangeNotes changeNotes,
+      PatchSet.Id sourcePatchSetId,
+      PatchSet targetPatchSet,
+      Account.Id approverId,
+      LabelType labelType,
+      short approvalValue,
+      ChangeKind changeKind,
+      boolean isMerge,
+      RevWalk revWalk,
+      Config repoConfig) {
+    if (!labelType.getCopyCondition().isPresent()) {
+      return ApprovalCopyResult.createForMissingCopyCondition();
+    }
+    ApprovalContext ctx =
+        ApprovalContext.create(
+            changeNotes,
+            sourcePatchSetId,
+            approverId,
+            labelType,
+            approvalValue,
+            targetPatchSet,
+            changeKind,
+            isMerge,
+            revWalk,
+            repoConfig);
+    try {
+      // Use a request context to run checks as an internal user with expanded visibility. This is
+      // so that the output of the copy condition does not depend on who is running the current
+      // request (e.g. a group used in this query might not be visible to the person sending this
+      // request).
+      try (ManualRequestContext ignored = requestContext.open()) {
+        Predicate<ApprovalContext> copyConditionPredicate =
+            approvalQueryBuilder.parse(labelType.getCopyCondition().get());
+        boolean canCopy = copyConditionPredicate.asMatchable().match(ctx);
+        ImmutableSet.Builder<String> passingAtomsBuilder = ImmutableSet.builder();
+        ImmutableSet.Builder<String> failingAtomsBuilder = ImmutableSet.builder();
+        evaluateAtoms(copyConditionPredicate, ctx, passingAtomsBuilder, failingAtomsBuilder);
+        ImmutableSet<String> passingAtoms = passingAtomsBuilder.build();
+        ImmutableSet<String> failingAtoms = failingAtomsBuilder.build();
+        logger.atFine().log(
+            "%s copy %s of account %d on change %d from patch set %d to patch set %d"
+                + " (copyCondition = %s, passingAtoms = %s, failingAtoms = %s, changeKind = %s)",
+            canCopy ? "Can" : "Cannot",
+            LabelVote.create(labelType.getName(), approvalValue).format(),
+            approverId.get(),
+            changeNotes.getChangeId().get(),
+            sourcePatchSetId.get(),
+            targetPatchSet.id().get(),
+            labelType.getCopyCondition().get(),
+            passingAtoms,
+            failingAtoms,
+            changeKind.name());
+        return ApprovalCopyResult.create(canCopy, passingAtoms, failingAtoms);
+      }
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log(
+          "Unable to copy label because config is invalid. This should have been caught before.");
+      return ApprovalCopyResult.createForNonParseableCopyCondition();
+    }
+  }
+
+  private Result computeForPatchSet(
+      LabelTypes labelTypes,
+      ChangeNotes notes,
+      PatchSet targetPatchSet,
+      RevWalk rw,
+      Config repoConfig) {
+    Project.NameKey projectName = notes.getProjectName();
+    PatchSet.Id targetPsId = targetPatchSet.id();
+
+    // Bail out immediately if this is the first patch set. Return only approvals granted on the
+    // given patch set.
+    if (targetPsId.get() == 1) {
+      return Result.empty();
+    }
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
+        notes.load().getPatchSets().lowerEntry(targetPsId);
+    if (priorPatchSet == null) {
+      return Result.empty();
+    }
+
+    Table<String, Account.Id, PatchSetApproval> currentApprovalsByUser = HashBasedTable.create();
+    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
+        notes.load().getApprovals().onlyNonCopied().get(targetPatchSet.id());
+    nonCopiedApprovalsForGivenPatchSet.forEach(
+        psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
+
+    Table<String, Account.Id, Result.PatchSetApprovalData> copiedApprovalsByUser =
+        HashBasedTable.create();
+    ImmutableSet.Builder<Result.PatchSetApprovalData> outdatedApprovalsBuilder =
+        ImmutableSet.builder();
+
+    ImmutableList<PatchSetApproval> priorApprovals =
+        notes.load().getApprovals().all().get(priorPatchSet.getKey());
+
+    // Add labels from the previous patch set to the result in case the label isn't already there
+    // and settings as well as change kind allow copying.
+    ChangeKind changeKind =
+        changeKindCache.getChangeKind(
+            projectName,
+            rw,
+            repoConfig,
+            priorPatchSet.getValue().commitId(),
+            targetPatchSet.commitId());
+    boolean isMerge = isMerge(projectName, rw, targetPatchSet);
+    logger.atFine().log(
+        "change kind for patch set %d of change %d against prior patch set %s is %s",
+        targetPatchSet.id().get(),
+        targetPatchSet.id().changeId().get(),
+        priorPatchSet.getValue().id().changeId(),
+        changeKind);
+
+    for (PatchSetApproval priorPsa : priorApprovals) {
+      if (priorPsa.value() == 0) {
+        // approvals with a zero vote record the deletion of a vote,
+        // they should neither be copied nor be reported as outdated, hence just skip them
+        continue;
+      }
+
+      Optional<LabelType> labelType = labelTypes.byLabel(priorPsa.labelId());
+      if (!labelType.isPresent()) {
+        logger.atFine().log(
+            "approval %d on label %s of patch set %d of change %d cannot be copied"
+                + " to patch set %d because the label no longer exists on project %s",
+            priorPsa.value(),
+            priorPsa.label(),
+            priorPsa.key().patchSetId().get(),
+            priorPsa.key().patchSetId().changeId().get(),
+            targetPsId.get(),
+            projectName);
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.createForMissingLabelType(priorPsa));
+        continue;
+      }
+      ApprovalCopyResult approvalCopyResult =
+          computeCopyResult(
+              notes,
+              priorPsa.patchSetId(),
+              targetPatchSet,
+              priorPsa.accountId(),
+              labelType.get(),
+              priorPsa.value(),
+              changeKind,
+              isMerge,
+              rw,
+              repoConfig);
+      if (approvalCopyResult.canCopy()) {
+        if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
+          PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
+
+          // Normalize the copied approval.
+          Optional<PatchSetApproval> copiedApprovalNormalized =
+              labelNormalizer.normalize(notes, copiedApproval);
+          logger.atFine().log(
+              "Copied approval %s has been normalized to %s",
+              copiedApproval,
+              copiedApprovalNormalized.map(PatchSetApproval::toString).orElse("n/a"));
+          if (!copiedApprovalNormalized.isPresent()) {
+            continue;
+          }
+
+          copiedApprovalsByUser.put(
+              priorPsa.label(),
+              priorPsa.accountId(),
+              Result.PatchSetApprovalData.create(
+                  copiedApprovalNormalized.get(),
+                  approvalCopyResult.passingAtoms(),
+                  approvalCopyResult.failingAtoms()));
+        }
+      } else {
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.create(
+                priorPsa, approvalCopyResult.passingAtoms(), approvalCopyResult.failingAtoms()));
+        continue;
+      }
+    }
+
+    return Result.create(
+        ImmutableSet.copyOf(copiedApprovalsByUser.values()), outdatedApprovalsBuilder.build());
+  }
+
+  private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
+    try {
+      return rw.parseCommit(patchSet.commitId()).getParentCount() > 1;
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "failed to check if patch set %d of change %s in project %s is a merge commit",
+              patchSet.id().get(), patchSet.id().changeId(), project),
+          e);
+    }
+  }
+
+  /**
+   * Evaluates a predicate of the copy condition and adds its passing and failing atoms to the given
+   * builders.
+   *
+   * @param predicate a predicate of the copy condition that should be evaluated
+   * @param approvalContext the approval context against which the predicate should be evaluated
+   * @param passingAtoms a builder to which passing atoms should be added
+   * @param failingAtoms a builder to which failing atoms should be added
+   */
+  private static void evaluateAtoms(
+      Predicate<ApprovalContext> predicate,
+      ApprovalContext approvalContext,
+      ImmutableSet.Builder<String> passingAtoms,
+      ImmutableSet.Builder<String> failingAtoms) {
+    if (predicate.isLeaf()) {
+      boolean isPassing = predicate.asMatchable().match(approvalContext);
+      (isPassing ? passingAtoms : failingAtoms).add(predicate.getPredicateString());
+      return;
+    }
+    predicate
+        .getChildren()
+        .forEach(
+            childPredicate ->
+                evaluateAtoms(childPredicate, approvalContext, passingAtoms, failingAtoms));
+  }
+
+  /** Result for checking if an approval can be copied to the next patch set. */
+  @AutoValue
+  abstract static class ApprovalCopyResult {
+    /** Whether the approval can be copied to the next patch set. */
+    abstract boolean canCopy();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> passingAtoms();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> failingAtoms();
+
+    private static ApprovalCopyResult create(
+        boolean canCopy, ImmutableSet<String> passingAtoms, ImmutableSet<String> failingAtoms) {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(canCopy, passingAtoms, failingAtoms);
+    }
+
+    private static ApprovalCopyResult createForMissingCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+
+    private static ApprovalCopyResult createForNonParseableCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
deleted file mode 100644
index 695997a..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ /dev/null
@@ -1,492 +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.approval;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Table;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.change.LabelNormalizer;
-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.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.approval.ApprovalContext;
-import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
-import com.google.gerrit.server.query.approval.ListOfFilesUnchangedPredicate;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Computes approvals for a given patch set by looking at approvals applied to the given patch set
- * and by additionally inferring approvals from the patch set's parents. The latter is done by
- * asserting a change's kind and checking the project config for allowed forward-inference.
- *
- * <p>The result of a copy may either be stored, as when stamping approvals in the database at
- * submit time, or refreshed on demand, as when reading approvals from the NoteDb. TODO(ghareeb):
- * migrate to new diff cache
- */
-@Singleton
-class ApprovalInference {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final DiffOperations diffOperations;
-  private final ProjectCache projectCache;
-  private final ChangeKindCache changeKindCache;
-  private final LabelNormalizer labelNormalizer;
-  private final ApprovalQueryBuilder approvalQueryBuilder;
-  private final OneOffRequestContext requestContext;
-  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
-
-  @Inject
-  ApprovalInference(
-      DiffOperations diffOperations,
-      ProjectCache projectCache,
-      ChangeKindCache changeKindCache,
-      LabelNormalizer labelNormalizer,
-      ApprovalQueryBuilder approvalQueryBuilder,
-      OneOffRequestContext requestContext,
-      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
-    this.diffOperations = diffOperations;
-    this.projectCache = projectCache;
-    this.changeKindCache = changeKindCache;
-    this.labelNormalizer = labelNormalizer;
-    this.approvalQueryBuilder = approvalQueryBuilder;
-    this.requestContext = requestContext;
-    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
-  }
-
-  /**
-   * Returns all approvals that apply to the given patch set. Honors direct and indirect (approval
-   * on parents) approvals.
-   */
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    PatchSet patchset = notes.getPatchSets().get(psId);
-    if (patchset == null) {
-      return Collections.emptyList();
-    }
-    return forPatchSet(notes, patchset, rw, repoConfig);
-  }
-
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet ps, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    ProjectState project;
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Computing labels for patch set",
-            Metadata.builder()
-                .changeId(notes.load().getChangeId().get())
-                .patchSetId(ps.id().get())
-                .build())) {
-      project =
-          projectCache
-              .get(notes.getProjectName())
-              .orElseThrow(illegalState(notes.getProjectName()));
-      Collection<PatchSetApproval> approvals =
-          getForPatchSetWithoutNormalization(notes, project, ps, rw, repoConfig);
-      return labelNormalizer.normalize(notes, approvals).getNormalized();
-    }
-  }
-
-  private boolean canCopyBasedOnBooleanLabelConfigs(
-      ProjectState project,
-      PatchSetApproval psa,
-      PatchSet.Id psId,
-      ChangeKind kind,
-      LabelType type,
-      @Nullable Map<String, FileDiffOutput> baseVsCurrentDiff,
-      @Nullable Map<String, FileDiffOutput> baseVsPriorDiff,
-      @Nullable Map<String, FileDiffOutput> priorVsCurrentDiff) {
-    int n = psa.key().patchSetId().get();
-    checkArgument(n != psId.get());
-
-    if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
-      logger.atFine().log(
-          "veto approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMinScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
-      logger.atFine().log(
-          "max approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMaxScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAnyScore()) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyAnyScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.getCopyValues().contains(psa.value())) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyValue = %d on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          psa.value(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && listOfFilesUnchangedPredicate.match(
-            baseVsCurrentDiff, baseVsPriorDiff, priorVsCurrentDiff)) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set "
-              + "copyAllScoresIfListOfFilesDidNotChange = true on "
-              + "project %s and list of files did not change (maybe except a rename, which is "
-              + "still the same file).",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    }
-    switch (kind) {
-      case MERGE_FIRST_PARENT_UPDATE:
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CODE_CHANGE:
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case TRIVIAL_REBASE:
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CHANGE:
-        if (type.isCopyAllScoresIfNoChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case REWORK:
-      default:
-        logger.atFine().log(
-            "approval %d on label %s of patch set %d of change %d cannot be copied"
-                + " to patch set %d because change kind is %s",
-            psa.value(), psa.label(), n, psa.key().patchSetId().changeId().get(), psId.get(), kind);
-        return false;
-    }
-  }
-
-  private boolean canCopyBasedOnCopyCondition(
-      ChangeNotes changeNotes,
-      PatchSetApproval psa,
-      PatchSet patchSet,
-      LabelType type,
-      ChangeKind changeKind) {
-    if (!type.getCopyCondition().isPresent()) {
-      return false;
-    }
-    ApprovalContext ctx = ApprovalContext.create(changeNotes, psa, patchSet, changeKind);
-    try {
-      // Use a request context to run checks as an internal user with expanded visibility. This is
-      // so that the output of the copy condition does not depend on who is running the current
-      // request (e.g. a group used in this query might not be visible to the person sending this
-      // request).
-      try (ManualRequestContext ignored = requestContext.open()) {
-        return approvalQueryBuilder.parse(type.getCopyCondition().get()).asMatchable().match(ctx);
-      }
-    } catch (QueryParseException e) {
-      logger.atWarning().withCause(e).log(
-          "Unable to copy label because config is invalid. This should have been caught before.");
-      return false;
-    }
-  }
-
-  private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
-      ChangeNotes notes,
-      ProjectState project,
-      PatchSet patchSet,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
-    checkState(
-        project.getNameKey().equals(notes.getProjectName()),
-        "project must match %s, %s",
-        project.getNameKey(),
-        notes.getProjectName());
-
-    PatchSet.Id psId = patchSet.id();
-    // Add approvals on the given patch set to the result
-    Table<String, Account.Id, PatchSetApproval> resultByUser = HashBasedTable.create();
-    ImmutableList<PatchSetApproval> approvalsForGivenPatchSet =
-        notes.load().getApprovals().get(patchSet.id());
-    approvalsForGivenPatchSet.forEach(psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
-
-    // Bail out immediately if this is the first patch set. Return only approvals granted on the
-    // given patch set.
-    if (psId.get() == 1) {
-      return resultByUser.values();
-    }
-
-    // Call this algorithm recursively to check if the prior patch set had approvals. This has the
-    // advantage that all caches - most importantly ChangeKindCache - have values cached for what we
-    // need for this computation.
-    // The way this algorithm is written is that any approval will be copied forward by one patch
-    // set at a time if configs and change kind allow so. Once an approval is held back - for
-    // example because the patch set is a REWORK - it will not be picked up again in a future
-    // patch set.
-    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
-    if (priorPatchSet == null) {
-      return resultByUser.values();
-    }
-
-    Iterable<PatchSetApproval> priorApprovals =
-        getForPatchSetWithoutNormalization(
-            notes, project, priorPatchSet.getValue(), rw, repoConfig);
-    if (!priorApprovals.iterator().hasNext()) {
-      return resultByUser.values();
-    }
-
-    // Add labels from the previous patch set to the result in case the label isn't already there
-    // and settings as well as change kind allow copying.
-    ChangeKind changeKind =
-        changeKindCache.getChangeKind(
-            project.getNameKey(),
-            rw,
-            repoConfig,
-            priorPatchSet.getValue().commitId(),
-            patchSet.commitId());
-    logger.atFine().log(
-        "change kind for patch set %d of change %d against prior patch set %s is %s",
-        patchSet.id().get(),
-        patchSet.id().changeId().get(),
-        priorPatchSet.getValue().id().changeId(),
-        changeKind);
-
-    Map<String, FileDiffOutput> baseVsCurrent = null;
-    Map<String, FileDiffOutput> baseVsPrior = null;
-    Map<String, FileDiffOutput> priorVsCurrent = null;
-    LabelTypes labelTypes = project.getLabelTypes();
-    for (PatchSetApproval psa : priorApprovals) {
-      if (resultByUser.contains(psa.label(), psa.accountId())) {
-        continue;
-      }
-      Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
-      // Only compute modified files if there is a relevant label, since this is expensive.
-      if (baseVsCurrent == null
-          && type.isPresent()
-          && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
-        baseVsCurrent = listModifiedFiles(project, patchSet);
-        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue());
-        priorVsCurrent =
-            listModifiedFiles(project, priorPatchSet.getValue().commitId(), patchSet.commitId());
-      }
-      if (!type.isPresent()) {
-        logger.atFine().log(
-            "approval %d on label %s of patch set %d of change %d cannot be copied"
-                + " to patch set %d because the label no longer exists on project %s",
-            psa.value(),
-            psa.label(),
-            psa.key().patchSetId().get(),
-            psa.key().patchSetId().changeId().get(),
-            psId.get(),
-            project.getName());
-        continue;
-      }
-      if (!canCopyBasedOnBooleanLabelConfigs(
-              project,
-              psa,
-              patchSet.id(),
-              changeKind,
-              type.get(),
-              baseVsCurrent,
-              baseVsPrior,
-              priorVsCurrent)
-          && !canCopyBasedOnCopyCondition(notes, psa, patchSet, type.get(), changeKind)) {
-        continue;
-      }
-      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
-    }
-    return resultByUser.values();
-  }
-
-  /**
-   * Gets the modified files between the two latest patch-sets. Can be used to compute difference in
-   * files between those two patch-sets .
-   */
-  private Map<String, FileDiffOutput> listModifiedFiles(ProjectState project, PatchSet ps) {
-    try {
-      Integer parentNum =
-          listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
-              ? 0
-              : 1;
-      return diffOperations.listModifiedFilesAgainstParent(
-          project.getNameKey(), ps.commitId(), parentNum);
-    } catch (DiffNotAvailableException ex) {
-      throw new StorageException(
-          "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
-
-  /**
-   * Gets the modified files between two commits corresponding to different patchsets of the same
-   * change.
-   */
-  private Map<String, FileDiffOutput> listModifiedFiles(
-      ProjectState project, ObjectId sourceCommit, ObjectId targetCommit) {
-    try {
-      return diffOperations.listModifiedFiles(project.getNameKey(), sourceCommit, targetCommit);
-    } catch (DiffNotAvailableException ex) {
-      throw new StorageException(
-          "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index c2e35d2..09820b1 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -15,19 +15,33 @@
 package com.google.gerrit.server.approval;
 
 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.ImmutableListMultimap.toImmutableListMultimap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+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.Function;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -39,9 +53,13 @@
 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.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -50,19 +68,27 @@
 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.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.query.approval.UserInPredicate;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Date;
+import java.util.HashSet;
 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.StringTokenizer;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -80,7 +106,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static PatchSetApproval.Builder newApproval(
-      PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Date when) {
+      PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Instant when) {
     PatchSetApproval.Builder b =
         PatchSetApproval.builder()
             .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
@@ -95,22 +121,34 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalInference approvalInference;
+  private final AccountCache accountCache;
+  private final String anonymousCowardName;
+  private final ApprovalCopier approvalCopier;
+  private final Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
-  private final ApprovalCache approvalCache;
+  private final LabelNormalizer labelNormalizer;
+  private final OneOffRequestContext requestContext;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalInference approvalInference,
+      AccountCache accountCache,
+      @AnonymousCowardName String anonymousCowardName,
+      ApprovalCopier approvalCopier,
+      Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      ApprovalCache approvalCache) {
-    this.approvalInference = approvalInference;
+      LabelNormalizer labelNormalizer,
+      OneOffRequestContext requestContext) {
+    this.accountCache = accountCache;
+    this.anonymousCowardName = anonymousCowardName;
+    this.approvalCopier = approvalCopier;
+    this.approvalQueryBuilderProvider = approvalQueryBuilderProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.approvalCache = approvalCache;
+    this.labelNormalizer = labelNormalizer;
+    this.requestContext = requestContext;
   }
 
   /**
@@ -223,10 +261,7 @@
           .statePermitsRead()) {
         return false;
       }
-      permissionBackend.absentUser(accountId).change(notes).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
+      return permissionBackend.absentUser(accountId).change(notes).test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log(
           "Failed to check if account %d can see change %d",
@@ -297,7 +332,7 @@
     }
     checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes()));
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
-    Date ts = update.getWhen();
+    Instant ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
       if (!lt.isPresent()) {
@@ -330,39 +365,500 @@
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       String name = vote.getKey();
       Short value = vote.getValue();
-      try {
-        forChange.check(new LabelPermission.WithValue(name, value));
-      } catch (AuthException e) {
+      if (!forChange.test(new LabelPermission.WithValue(name, value))) {
         throw new AuthException(
-            String.format("applying label \"%s\": %d is restricted", name, value), e);
+            String.format("applying label \"%s\": %d is restricted", name, value));
       }
     }
   }
 
-  public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ChangeNotes notes) {
-    return notes.load().getApprovals();
+  public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
+      ChangeNotes notes) {
+    return notes.load().getApprovals().onlyNonCopied();
   }
 
-  public Iterable<PatchSetApproval> byPatchSet(
-      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    return approvalInference.forPatchSet(notes, psId, rw, repoConfig);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet patchSet) {
-    return approvalInference.forPatchSet(notes, patchSet, /* rw= */ null, /* repoConfig= */ null);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    return approvalCache.get(notes, psId);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSetUser(
+  /**
+   * Copies approvals to a new patch set.
+   *
+   * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and
+   * stores them in NoteDb.
+   *
+   * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch
+   * set and hence not copied) the approvers are added to the attention set since they need to
+   * re-review the change and renew their approvals.
+   *
+   * @param notes the change notes
+   * @param patchSet the newly created patch set
+   * @param revWalk {@link RevWalk} that can see the new patch set revision
+   * @param repoConfig the repo config
+   * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
+   *     attention set
+   * @return the result of the approval copying
+   */
+  public ApprovalCopier.Result copyApprovalsToNewPatchSet(
       ChangeNotes notes,
-      PatchSet.Id psId,
-      Account.Id accountId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
-    return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
+      PatchSet patchSet,
+      RevWalk revWalk,
+      Config repoConfig,
+      ChangeUpdate changeUpdate) {
+    ApprovalCopier.Result approvalCopierResult =
+        approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
+    approvalCopierResult
+        .copiedApprovals()
+        .forEach(approvalData -> changeUpdate.putCopiedApproval(approvalData.patchSetApproval()));
+
+    if (!notes.getChange().isWorkInProgress()) {
+      // The attention set should not be updated when the change is work-in-progress.
+      addAttentionSetUpdatesForOutdatedApprovals(
+          changeUpdate,
+          approvalCopierResult.outdatedApprovals().stream()
+              .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+              .collect(toImmutableSet()));
+    }
+
+    return approvalCopierResult;
+  }
+
+  private void addAttentionSetUpdatesForOutdatedApprovals(
+      ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+
+    Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
+    outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
+    for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
+        outdatedApprovalsByUser.asMap().entrySet()) {
+      Account.Id approverId = e.getKey();
+      Collection<PatchSetApproval> outdatedUserApprovals = e.getValue();
+
+      String message;
+      if (outdatedUserApprovals.size() == 1) {
+        PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals);
+        message =
+            String.format(
+                "Vote got outdated and was removed: %s",
+                LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value())
+                    .format());
+      } else {
+        message =
+            String.format(
+                "Votes got outdated and were removed: %s",
+                outdatedUserApprovals.stream()
+                    .map(
+                        outdatedUserApproval ->
+                            LabelVote.create(
+                                    outdatedUserApproval.label(), outdatedUserApproval.value())
+                                .format())
+                    .sorted()
+                    .collect(joining(", ")));
+      }
+
+      updates.add(
+          AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message));
+    }
+    changeUpdate.addToPlannedAttentionSetUpdates(updates);
+  }
+
+  public Optional<String> formatApprovalCopierResult(
+      ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) {
+    requireNonNull(approvalCopierResult, "approvalCopierResult");
+    requireNonNull(labelTypes, "labelTypes");
+
+    if (approvalCopierResult.copiedApprovals().isEmpty()
+        && approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    StringBuilder message = new StringBuilder();
+
+    if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+      message.append("Copied Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes));
+    }
+    if (!approvalCopierResult.outdatedApprovals().isEmpty()) {
+      if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+        message.append("\n");
+      }
+      message.append("Outdated Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(
+              approvalCopierResult.outdatedApprovals(), labelTypes));
+    }
+
+    return Optional.of(message.toString());
+  }
+
+  /**
+   * Formats the given approvals as a bullet list, each approval with the corresponding copy
+   * condition if available.
+   *
+   * <p>E.g.:
+   *
+   * <pre>
+   * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN")
+   * * Verified+1 (copy condition: "is:MIN")
+   * </pre>
+   *
+   * <p>Entries in the list can have the following formats:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if
+   *       the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is
+   *       missing)}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvalDatas the approvals that should be formatted, with approval meta data
+   * @param labelTypes the label types
+   * @return bullet list with the formatted approvals
+   */
+  private String formatApprovalListWithCopyCondition(
+      ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+      LabelTypes labelTypes) {
+    StringBuilder message = new StringBuilder();
+
+    // sort approvals by label vote so that we list them in a deterministic order
+    ImmutableList<ApprovalCopier.Result.PatchSetApprovalData> approvalsSortedByLabelVote =
+        approvalDatas.stream()
+            .sorted(
+                comparing(
+                    approvalData ->
+                        LabelVote.create(
+                                approvalData.patchSetApproval().label(),
+                                approvalData.patchSetApproval().value())
+                            .format()))
+            .collect(toImmutableList());
+
+    ImmutableListMultimap<String, ApprovalCopier.Result.PatchSetApprovalData> approvalsByLabel =
+        Multimaps.index(
+            approvalsSortedByLabelVote, approvalData -> approvalData.patchSetApproval().label());
+
+    for (Map.Entry<String, Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+        approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) {
+      String label = approvalsByLabelEntry.getKey();
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabel =
+          approvalsByLabelEntry.getValue();
+
+      if (!labelTypes.byLabel(label).isPresent()) {
+        message
+            .append("* ")
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append(" (label type is missing)\n");
+        continue;
+      }
+
+      LabelType labelType = labelTypes.byLabel(label).get();
+      if (!labelType.getCopyCondition().isPresent()) {
+        message
+            .append("* ")
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append("\n");
+        continue;
+      }
+
+      // Group the approvals that have the same label by the passing atoms. If approvals have the
+      // same label, but have different passing atoms, we need to list them in separate lines
+      // (because in each line we will highlight different passing atoms that matched). Approvals
+      // with the same label and the same passing atoms are formatted as a single line.
+      ImmutableListMultimap<ImmutableSet<String>, ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelByPassingAndFailingAtoms =
+              Multimaps.index(
+                  approvalsForSameLabel, ApprovalCopier.Result.PatchSetApprovalData::passingAtoms);
+
+      // Approvals with the same label that have the same passing atoms should have the same failing
+      // atoms (since the label is the same they have the same copy condition).
+      approvalsForSameLabelByPassingAndFailingAtoms
+          .asMap()
+          .values()
+          .forEach(
+              approvalsForSameLabelAndSamePassingAtoms ->
+                  checkThatPropertyIsTheSameForAllApprovals(
+                      approvalsForSameLabelAndSamePassingAtoms,
+                      "failing atoms",
+                      approvalData -> approvalData.failingAtoms()));
+
+      // The order in which we add lines for approvals with the same label but different passing
+      // atoms needs to be deterministic for tests. Just sort them by the string representation of
+      // the passing atoms.
+      for (Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelWithSamePassingAndFailingAtoms :
+              approvalsForSameLabelByPassingAndFailingAtoms.asMap().entrySet().stream()
+                  .sorted(
+                      comparing(
+                          (Map.Entry<
+                                      ImmutableSet<String>,
+                                      Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+                                  e) -> e.getKey().toString()))
+                  .map(Map.Entry::getValue)
+                  .collect(toImmutableList())) {
+        message
+            .append("* ")
+            .append(
+                formatApprovalsWithCopyCondition(
+                    approvalsForSameLabelWithSamePassingAndFailingAtoms,
+                    labelType.getCopyCondition().get()))
+            .append("\n");
+      }
+    }
+
+    return message.toString();
+  }
+
+  /**
+   * Formats the given approvals with the given copy condition.
+   *
+   * <p>The given approvals must have the same label and the same passing and failing atoms.
+   *
+   * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *
+   * <p>The following format may be returned:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvalsWithSameLabelAndSamePassingAndFailingAtoms the approvals that should be
+   *     formatted, must be for the same label
+   * @param copyCondition the copy condition of the label
+   * @return the formatted approvals
+   */
+  private String formatApprovalsWithCopyCondition(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+      String copyCondition) {
+    // Check that all given approvals have the same label and the same passing and failing atoms.
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "label",
+        approvalData -> approvalData.patchSetApproval().label());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "passing atoms",
+        approvalData -> approvalData.passingAtoms());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "failing atoms",
+        approvalData -> approvalData.failingAtoms());
+
+    StringBuilder message = new StringBuilder();
+
+    boolean containsUserInPredicate;
+    try {
+      containsUserInPredicate = containsUserInPredicate(copyCondition);
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log("Non-parsable query condition");
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
+      message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
+      return message.toString();
+    }
+
+    if (containsUserInPredicate) {
+      // If a UserInPredicate is used (e.g. 'approverin:<group>' or 'uploaderin:<group>') we need to
+      // include the approvers into the change message since they are relevant for the matching. For
+      // example it can happen that the same approval of different users is copied for the one user
+      // but not for the other user (since the one user is a member of the approverin group and the
+      // other user isn't).
+      //
+      // Example:
+      // * label Foo has the copy condition 'is:ANY approverin:123'
+      // * group 123 contains UserA as member, but not UserB
+      // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB
+      //
+      // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy
+      // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of
+      // group 123 and the copy condition doesn't match.
+      //
+      // So it can happen that the same approval Foo+1, but by different users, is copied and
+      // outdated at the same time. To allow users to understand that the copying depends on who did
+      // the approval, the approvers must be included into the change message.
+
+      // sort the approvals by their approvers name-email so that the approvers always appear in a
+      // deterministic order
+      ImmutableList<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsSortedByLabelVoteAndApprover =
+              approvalsWithSameLabelAndSamePassingAndFailingAtoms.stream()
+                  .sorted(
+                      comparing(
+                              (ApprovalCopier.Result.PatchSetApprovalData approvalData) ->
+                                  LabelVote.create(
+                                          approvalData.patchSetApproval().label(),
+                                          approvalData.patchSetApproval().value())
+                                      .format())
+                          .thenComparing(
+                              approvalData ->
+                                  accountCache
+                                      .getEvenIfMissing(approvalData.patchSetApproval().accountId())
+                                      .account()
+                                      .getNameEmail(anonymousCowardName)))
+                  .collect(toImmutableList());
+
+      ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
+          Multimaps.index(
+                  approvalsSortedByLabelVoteAndApprover,
+                  approvalData ->
+                      LabelVote.create(
+                          approvalData.patchSetApproval().label(),
+                          approvalData.patchSetApproval().value()))
+              .entries().stream()
+              .collect(
+                  toImmutableListMultimap(
+                      e -> e.getKey(), e -> e.getValue().patchSetApproval().accountId()));
+      message.append(
+          approversByLabelVote.asMap().entrySet().stream()
+              .map(
+                  approversByLabelVoteEntry ->
+                      formatLabelVoteWithApprovers(
+                          approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue()))
+              .collect(joining(", ")));
+    } else {
+      // copy condition doesn't contain a UserInPredicate
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
+    }
+    ImmutableSet<String> passingAtoms =
+        !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty()
+            ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.iterator().next().passingAtoms()
+            : ImmutableSet.of();
+    message.append(
+        String.format(
+            " (copy condition: \"%s\")",
+            formatCopyConditionAsMarkdown(copyCondition, passingAtoms)));
+    return message.toString();
+  }
+
+  /** Checks that all given approvals have the same value for a given property. */
+  private void checkThatPropertyIsTheSameForAllApprovals(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+      String propertyName,
+      Function<ApprovalCopier.Result.PatchSetApprovalData, ?> propertyExtractor) {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    Object propertyOfFirstEntry = propertyExtractor.apply(approvals.iterator().next());
+    approvals.forEach(
+        approvalData ->
+            checkState(
+                propertyExtractor.apply(approvalData).equals(propertyOfFirstEntry),
+                "property %s of approval %s does not match, expected value: %s",
+                propertyName,
+                approvalData,
+                propertyOfFirstEntry));
+  }
+
+  /**
+   * Formats the given copy condition as a Markdown string.
+   *
+   * <p>Passing atoms are formatted as bold.
+   *
+   * @param copyCondition the copy condition that should be formatted
+   * @param passingAtoms atoms of the copy conditions which are passing/matching
+   * @return the formatted copy condition as a Markdown string
+   */
+  private String formatCopyConditionAsMarkdown(
+      String copyCondition, ImmutableSet<String> passingAtoms) {
+    StringBuilder formattedCopyCondition = new StringBuilder();
+    StringTokenizer tokenizer = new StringTokenizer(copyCondition, " ()", /* returnDelims= */ true);
+    while (tokenizer.hasMoreTokens()) {
+      String token = tokenizer.nextToken();
+      if (passingAtoms.contains(token)) {
+        formattedCopyCondition.append("**" + token.replace("*", "\\*") + "**");
+      } else {
+        formattedCopyCondition.append(token);
+      }
+    }
+    return formattedCopyCondition.toString();
+  }
+
+  private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
+    // Use a request context to run checks as an internal user with expanded visibility. This is
+    // so that the output of the copy condition does not depend on who is running the current
+    // request (e.g. a group used in this query might not be visible to the person sending this
+    // request).
+    try (ManualRequestContext ignored = requestContext.open()) {
+      return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList()
+          .stream()
+          .anyMatch(UserInPredicate.class::isInstance);
+    }
+  }
+
+  /**
+   * Formats the given approvals as a comma-separated list of label votes.
+   *
+   * <p>E.g.: {@code Code-Review+1, CodeReview+2}
+   *
+   * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated
+   *     list of label votes, must be sorted
+   * @return the given approvals as a comma-separated list of label votes
+   */
+  private String formatApprovalsAsLabelVotesList(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> sortedApprovalsForSameLabel) {
+    return sortedApprovalsForSameLabel.stream()
+        .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+        .map(psa -> LabelVote.create(psa.label(), psa.value()))
+        .distinct()
+        .map(LabelVote::format)
+        .collect(joining(", "));
+  }
+
+  /**
+   * Formats the given label vote with a comma-separated list of the given approvers.
+   *
+   * <p>E.g.: {@code Code-Review+1 by <user1-placeholder>, <user2-placeholder>}
+   *
+   * @param labelVote the label vote that should be formatted with a comma-separated list of the
+   *     given approver
+   * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the
+   *     given label vote
+   * @return the given label vote with a comma-separated list of the given approvers
+   */
+  private String formatLabelVoteWithApprovers(
+      LabelVote labelVote, Collection<Account.Id> sortedApprovers) {
+    return new StringBuilder()
+        .append(labelVote.format())
+        .append(" by ")
+        .append(
+            sortedApprovers.stream()
+                .map(AccountTemplateUtil::getAccountTemplate)
+                .collect(joining(", ")))
+        .toString();
+  }
+
+  /**
+   * Gets {@link PatchSetApproval}s for a specified patch-set. The result includes copied votes but
+   * does not include deleted labels.
+   *
+   * @param notes changenotes of the change.
+   * @param psId patch-set id for the change and patch-set we want to get approvals.
+   * @return all approvals for the specified patch-set, including copied votes, not including
+   *     deleted labels.
+   */
+  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
+    return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
@@ -370,18 +866,20 @@
     return filterApprovals(byPatchSet(notes, psId), accountId);
   }
 
+  @Nullable
   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(notes).get(c));
+      // Submit approval is never copied.
+      return getSubmitter(c, byChangeExcludingCopiedApprovals(notes).get(c));
     } catch (StorageException e) {
       return null;
     }
   }
 
+  @Nullable
   public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
     if (c == null) {
       return null;
diff --git a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java
new file mode 100644
index 0000000..128bee4
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.inject.ImplementedBy;
+import java.time.Instant;
+
+/**
+ * Generator for {@link com.google.gerrit.entities.PatchSetApproval.UUID}.
+ *
+ * <p>Since {@link com.google.gerrit.entities.PatchSetApproval.UUID} must be unique for each granted
+ * {@link PatchSetApproval}, implementations must generate globally unique UUID for each {@link
+ * #get} invocation.
+ */
+@ImplementedBy(PatchSetApprovalUuidGeneratorImpl.class)
+public interface PatchSetApprovalUuidGenerator {
+
+  /**
+   * Generates {@link com.google.gerrit.entities.PatchSetApproval.UUID} based on the properties of
+   * {@link PatchSetApproval} that is being granted.
+   */
+  UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted);
+}
diff --git a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
new file mode 100644
index 0000000..afa0384
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.inject.Singleton;
+import java.security.MessageDigest;
+import java.time.Instant;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Default implementation of {@link
+ * com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator}.
+ */
+@Singleton
+public class PatchSetApprovalUuidGeneratorImpl implements PatchSetApprovalUuidGenerator {
+  @Override
+  public UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted) {
+    MessageDigest md = Constants.newMessageDigest();
+    md.update(
+        Constants.encode("patchSetId " + patchSetId.getCommaSeparatedChangeAndPatchSetId() + "\n"));
+    md.update(Constants.encode("accountId " + accountId + "\n"));
+    md.update(Constants.encode("label " + label + "\n"));
+    md.update(Constants.encode("value " + value + "\n"));
+    md.update(Constants.encode("granted " + granted.toEpochMilli() + "\n"));
+    md.update(Constants.encode(String.valueOf(Math.random())));
+    return PatchSetApproval.uuid(ObjectId.fromRaw(md.digest()).name());
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
new file mode 100644
index 0000000..89727c7
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval.testing;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import java.time.Instant;
+import javax.inject.Singleton;
+
+/**
+ * Implementation of {@link PatchSetApprovalUuidGenerator} that returns predictable {@link UUID}.
+ */
+@Singleton
+public class TestPatchSetApprovalUuidGenerator implements PatchSetApprovalUuidGenerator {
+
+  private int invocationCount = 0;
+
+  @Override
+  public UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted) {
+    invocationCount++;
+    return PatchSetApproval.uuid(
+        String.format(
+                "%s_%s_%s_%s_%s_%s",
+                patchSetId.changeId().get(),
+                patchSetId.get(),
+                accountId.get(),
+                label,
+                value,
+                invocationCount)
+            .replace("-", "_")
+            .toLowerCase());
+  }
+}
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 5df4d28..94dc4c3 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -90,9 +90,9 @@
         }
       }
     } catch (StorageException e) {
-      String msg = "database is down";
-      logger.atSevere().withCause(e).log(msg);
-      throw new CmdLineException(owner, localizable(msg));
+      CmdLineException newException = new CmdLineException(owner, localizable("database is down"));
+      newException.initCause(e);
+      throw newException;
     } catch (IOException e) {
       throw new CmdLineException(owner, "Failed to load account", e);
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/args4j/InstantHandler.java b/java/com/google/gerrit/server/args4j/InstantHandler.java
new file mode 100644
index 0000000..bfca0f6
--- /dev/null
+++ b/java/com/google/gerrit/server/args4j/InstantHandler.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.args4j;
+
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+
+public class InstantHandler extends OptionHandler<Instant> {
+  public static final String TIMESTAMP_FORMAT = "yyyyMMdd_HHmm";
+
+  @Inject
+  public InstantHandler(
+      @Assisted CmdLineParser parser,
+      @Assisted OptionDef option,
+      @Assisted Setter<Instant> setter) {
+    super(parser, option, setter);
+  }
+
+  @Override
+  public int parseArguments(Parameters params) throws CmdLineException {
+    String timestamp = params.getParameter(0);
+    try {
+      setter.addValue(
+          DateTimeFormatter.ofPattern(TIMESTAMP_FORMAT)
+              .withZone(ZoneId.of("UTC"))
+              .parse(timestamp, Instant::from));
+      return 1;
+    } catch (DateTimeParseException e) {
+      throw new CmdLineException(
+          owner,
+          String.format("Invalid timestamp: %s; expected format: %s", timestamp, TIMESTAMP_FORMAT),
+          e);
+    }
+  }
+
+  @Override
+  public String getDefaultMetaVariable() {
+    return "TIMESTAMP";
+  }
+}
diff --git a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
index aa8a958..3cad7ce 100644
--- a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
@@ -16,9 +16,11 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.NamedOptionDef;
 import org.kohsuke.args4j.OptionDef;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Parameters;
@@ -37,7 +39,14 @@
   @Override
   public int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    setter.addValue(ObjectId.fromString(n));
+    try {
+      setter.addValue(ObjectId.fromString(n));
+    } catch (InvalidObjectIdException e) {
+      throw new CmdLineException(
+          owner,
+          String.format("expected SHA1 for option %s: %s", ((NamedOptionDef) option).name(), n),
+          e);
+    }
     return 1;
   }
 
diff --git a/java/com/google/gerrit/server/args4j/TimestampHandler.java b/java/com/google/gerrit/server/args4j/TimestampHandler.java
deleted file mode 100644
index eddfbcd..0000000
--- a/java/com/google/gerrit/server/args4j/TimestampHandler.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.server.args4j;
-
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.TimeZone;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.OptionDef;
-import org.kohsuke.args4j.spi.OptionHandler;
-import org.kohsuke.args4j.spi.Parameters;
-import org.kohsuke.args4j.spi.Setter;
-
-public class TimestampHandler extends OptionHandler<Timestamp> {
-  public static final String TIMESTAMP_FORMAT = "yyyyMMdd_HHmm";
-
-  @Inject
-  public TimestampHandler(
-      @Assisted CmdLineParser parser,
-      @Assisted OptionDef option,
-      @Assisted Setter<Timestamp> setter) {
-    super(parser, option, setter);
-  }
-
-  @Override
-  public int parseArguments(Parameters params) throws CmdLineException {
-    String timestamp = params.getParameter(0);
-    try {
-      DateFormat fmt = new SimpleDateFormat(TIMESTAMP_FORMAT);
-      fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
-      setter.addValue(new Timestamp(fmt.parse(timestamp).getTime()));
-      return 1;
-    } catch (ParseException e) {
-      throw new CmdLineException(
-          owner,
-          String.format("Invalid timestamp: %s; expected format: %s", timestamp, TIMESTAMP_FORMAT),
-          e);
-    }
-  }
-
-  @Override
-  public String getDefaultMetaVariable() {
-    return "TIMESTAMP";
-  }
-}
diff --git a/java/com/google/gerrit/server/audit/AuditService.java b/java/com/google/gerrit/server/audit/AuditService.java
index 2a5d868..695c32a 100644
--- a/java/com/google/gerrit/server/audit/AuditService.java
+++ b/java/com/google/gerrit/server/audit/AuditService.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @Singleton
 public class AuditService implements GroupAuditService {
@@ -50,7 +50,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> addedMembers,
-      Timestamp addedOn) {
+      Instant addedOn) {
     GroupMemberAuditEvent event =
         GroupMemberAuditEvent.create(actor, updatedGroup, addedMembers, addedOn);
     groupAuditListeners.runEach(l -> l.onAddMembers(event));
@@ -61,7 +61,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> deletedMembers,
-      Timestamp deletedOn) {
+      Instant deletedOn) {
     GroupMemberAuditEvent event =
         GroupMemberAuditEvent.create(actor, updatedGroup, deletedMembers, deletedOn);
     groupAuditListeners.runEach(l -> l.onDeleteMembers(event));
@@ -72,7 +72,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> addedSubgroups,
-      Timestamp addedOn) {
+      Instant addedOn) {
     GroupSubgroupAuditEvent event =
         GroupSubgroupAuditEvent.create(actor, updatedGroup, addedSubgroups, addedOn);
     groupAuditListeners.runEach(l -> l.onAddSubgroups(event));
@@ -83,7 +83,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> deletedSubgroups,
-      Timestamp deletedOn) {
+      Instant deletedOn) {
     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
index 3faa259..bde7404 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -58,7 +58,7 @@
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/flogger:api",
@@ -66,7 +66,6 @@
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
         "//lib/jsoup",
-        "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-queryparser",
         "//lib/mime4j:core",
diff --git a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
index 252a1e2..c37c583 100644
--- a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** An audit event for groups. */
 public interface GroupAuditEvent {
@@ -35,9 +35,9 @@
   AccountGroup.UUID getUpdatedGroup();
 
   /**
-   * Gets the {@link Timestamp} of the action.
+   * Gets the {@link Instant} of the action.
    *
-   * @return the {@link Timestamp} of the action.
+   * @return the {@link Instant} of the action.
    */
-  Timestamp getTimestamp();
+  Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
index eccfbf4..95a2dce 100644
--- a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @AutoValue
 public abstract class GroupMemberAuditEvent implements GroupAuditEvent {
@@ -26,7 +26,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> modifiedMembers,
-      Timestamp timestamp) {
+      Instant timestamp) {
     return new AutoValue_GroupMemberAuditEvent(actor, updatedGroup, modifiedMembers, timestamp);
   }
 
@@ -40,5 +40,5 @@
   public abstract ImmutableSet<Account.Id> getModifiedMembers();
 
   @Override
-  public abstract Timestamp getTimestamp();
+  public abstract Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
index 0fe3962..ace8312 100644
--- a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @AutoValue
 public abstract class GroupSubgroupAuditEvent implements GroupAuditEvent {
@@ -26,7 +26,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> modifiedSubgroups,
-      Timestamp timestamp) {
+      Instant timestamp) {
     return new AutoValue_GroupSubgroupAuditEvent(actor, updatedGroup, modifiedSubgroups, timestamp);
   }
 
@@ -40,5 +40,5 @@
   public abstract ImmutableSet<AccountGroup.UUID> getModifiedSubgroups();
 
   @Override
-  public abstract Timestamp getTimestamp();
+  public abstract Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/cache/CacheBackend.java b/java/com/google/gerrit/server/cache/CacheBackend.java
deleted file mode 100644
index ec9876f..0000000
--- a/java/com/google/gerrit/server/cache/CacheBackend.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// 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;
-
-/** Caffeine is used as default cache backend, but can be overridden with Guava backend. */
-public enum CacheBackend {
-  CAFFEINE,
-  GUAVA;
-
-  public boolean isLegacyBackend() {
-    return this == GUAVA;
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/CacheDisplay.java b/java/com/google/gerrit/server/cache/CacheDisplay.java
new file mode 100644
index 0000000..60f5186
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/CacheDisplay.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.common.base.Strings;
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Collection;
+
+public class CacheDisplay {
+
+  private final Writer stdout;
+  private final int nw;
+  private final Collection<CacheInfo> caches;
+
+  public CacheDisplay(Writer stdout, int nw, Collection<CacheInfo> caches) {
+    this.stdout = stdout;
+    this.nw = nw;
+    this.caches = caches;
+  }
+
+  public CacheDisplay(Writer stdout, Collection<CacheInfo> caches) {
+    this(stdout, 30, caches);
+  }
+
+  public void displayCaches() throws IOException {
+    stdout.write(
+        String.format( //
+            "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
+            ,
+            "" //
+            ,
+            "Name" //
+            ,
+            "Entries" //
+            ,
+            "AvgGet" //
+            ,
+            "Hit Ratio" //
+            ));
+    stdout.write(
+        String.format( //
+            "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
+            ,
+            "" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ,
+            "Space" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ));
+    stdout.write("--");
+    for (int i = 0; i < nw; i++) {
+      stdout.write('-');
+    }
+    stdout.write("+---------------------+---------+---------+\n");
+    printMemoryCoreCaches(caches);
+    printMemoryPluginCaches(caches);
+    printDiskCaches(caches);
+    stdout.write('\n');
+  }
+
+  private void printMemoryCoreCaches(Collection<CacheInfo> caches) throws IOException {
+    for (CacheInfo cache : caches) {
+      if (!cache.name.contains("-") && CacheInfo.CacheType.MEM.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printMemoryPluginCaches(Collection<CacheInfo> caches) throws IOException {
+    for (CacheInfo cache : caches) {
+      if (cache.name.contains("-") && CacheInfo.CacheType.MEM.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printDiskCaches(Collection<CacheInfo> caches) throws IOException {
+    for (CacheInfo cache : caches) {
+      if (CacheInfo.CacheType.DISK.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printCache(CacheInfo cache) throws IOException {
+    stdout.write(
+        String.format(
+            "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
+            CacheInfo.CacheType.DISK.equals(cache.type) ? "D" : "",
+            cache.name,
+            nullToEmpty(cache.entries.mem),
+            nullToEmpty(cache.entries.disk),
+            Strings.nullToEmpty(cache.entries.space),
+            Strings.nullToEmpty(cache.averageGet),
+            formatAsPercent(cache.hitRatio.mem),
+            formatAsPercent(cache.hitRatio.disk)));
+  }
+
+  private static String nullToEmpty(Long l) {
+    return l != null ? String.valueOf(l) : "";
+  }
+
+  private static String formatAsPercent(Integer i) {
+    return i != null ? i + "%" : "";
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/CacheInfo.java b/java/com/google/gerrit/server/cache/CacheInfo.java
new file mode 100644
index 0000000..94a9e05
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/CacheInfo.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.common.cache.Cache;
+import com.google.common.cache.CacheStats;
+import com.google.gerrit.common.Nullable;
+
+public class CacheInfo {
+
+  public String name;
+  public CacheType type;
+  public EntriesInfo entries;
+  public String averageGet;
+  public HitRatioInfo hitRatio;
+
+  public CacheInfo(Cache<?, ?> cache) {
+    this(null, cache);
+  }
+
+  public CacheInfo(String name, Cache<?, ?> cache) {
+    this.name = name;
+
+    CacheStats stat = cache.stats();
+
+    entries = new EntriesInfo();
+    entries.setMem(cache.size());
+
+    averageGet = duration(stat.averageLoadPenalty());
+
+    hitRatio = new HitRatioInfo();
+    hitRatio.setMem(stat.hitCount(), stat.requestCount());
+
+    if (cache instanceof PersistentCache) {
+      type = CacheType.DISK;
+      PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
+      entries.setDisk(diskStats.size());
+      entries.setSpace(diskStats.space());
+      hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
+    } else {
+      type = CacheType.MEM;
+    }
+  }
+
+  @Nullable
+  private static String duration(double ns) {
+    if (ns < 0.5) {
+      return null;
+    }
+    String suffix = "ns";
+    if (ns >= 1000.0) {
+      ns /= 1000.0;
+      suffix = "us";
+    }
+    if (ns >= 1000.0) {
+      ns /= 1000.0;
+      suffix = "ms";
+    }
+    if (ns >= 1000.0) {
+      ns /= 1000.0;
+      suffix = "s";
+    }
+    return String.format("%4.1f%s", ns, suffix).trim();
+  }
+
+  public static class EntriesInfo {
+    public Long mem;
+    public Long disk;
+    public String space;
+
+    public void setMem(long mem) {
+      this.mem = mem != 0 ? mem : null;
+    }
+
+    public void setDisk(long disk) {
+      this.disk = disk != 0 ? disk : null;
+    }
+
+    public void setSpace(double value) {
+      space = bytes(value);
+    }
+
+    private static String bytes(double value) {
+      value /= 1024;
+      String suffix = "k";
+
+      if (value > 1024) {
+        value /= 1024;
+        suffix = "m";
+      }
+      if (value > 1024) {
+        value /= 1024;
+        suffix = "g";
+      }
+      return String.format("%1$6.2f%2$s", value, suffix).trim();
+    }
+  }
+
+  public static class HitRatioInfo {
+    public Integer mem;
+    public Integer disk;
+
+    public void setMem(long value, long total) {
+      mem = percent(value, total);
+    }
+
+    public void setDisk(long value, long total) {
+      disk = percent(value, total);
+    }
+
+    @Nullable
+    private static Integer percent(long value, long total) {
+      if (total <= 0) {
+        return null;
+      }
+      return (int) ((100 * value) / total);
+    }
+  }
+
+  public enum CacheType {
+    MEM,
+    DISK
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index 0fdc6f5..d4e4509 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -35,7 +35,7 @@
   public static final String MEMORY_MODULE = "cache-memory";
   public static final String PERSISTENT_MODULE = "cache-persistent";
 
-  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<Cache<?, ?>>() {};
+  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<>() {};
 
   /**
    * Declare a named in-memory cache.
@@ -68,8 +68,7 @@
    */
   protected <K, V> CacheBinding<K, V> cache(
       String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    CacheProvider<K, V> m =
-        new CacheProvider<>(this, name, keyType, valType, CacheBackend.CAFFEINE);
+    CacheProvider<K, V> m = new CacheProvider<>(this, name, keyType, valType);
     bindCache(m, name, keyType, valType);
     return m;
   }
@@ -124,20 +123,7 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, Class<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), CacheBackend.CAFFEINE);
-  }
-
-  /**
-   * Declare a named in-memory/on-disk cache.
-   *
-   * @param <K> type of key used to lookup entries.
-   * @param <V> type of value stored by the cache.
-   * @param backend cache backend.
-   * @return binding to describe the cache.
-   */
-  protected <K, V> PersistentCacheBinding<K, V> persist(
-      String name, Class<K> keyType, Class<V> valType, CacheBackend backend) {
-    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), backend);
+    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
   }
 
   /**
@@ -149,7 +135,7 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, TypeLiteral<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), valType, CacheBackend.CAFFEINE);
+    return persist(name, TypeLiteral.get(keyType), valType);
   }
 
   /**
@@ -160,9 +146,8 @@
    * @return binding to describe the cache.
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
-      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType, CacheBackend backend) {
-    PersistentCacheProvider<K, V> m =
-        new PersistentCacheProvider<>(this, name, keyType, valType, backend);
+      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+    PersistentCacheProvider<K, V> m = new PersistentCacheProvider<>(this, name, keyType, valType);
     bindCache(m, name, keyType, valType);
 
     Type cacheDefType =
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index 2dd9e1f..94504b6 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -30,7 +30,6 @@
 
 class CacheProvider<K, V> implements Provider<Cache<K, V>>, CacheBinding<K, V>, CacheDef<K, V> {
   private final CacheModule module;
-  private final CacheBackend backend;
   final String name;
   private final TypeLiteral<K> keyType;
   private final TypeLiteral<V> valType;
@@ -46,17 +45,11 @@
   private MemoryCacheFactory memoryCacheFactory;
   private boolean frozen;
 
-  CacheProvider(
-      CacheModule module,
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType,
-      CacheBackend backend) {
+  CacheProvider(CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
     this.module = module;
     this.name = name;
     this.keyType = keyType;
     this.valType = valType;
-    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -179,9 +172,7 @@
   public Cache<K, V> get() {
     freeze();
     CacheLoader<K, V> ldr = loader();
-    return ldr != null
-        ? memoryCacheFactory.build(this, ldr, backend)
-        : memoryCacheFactory.build(this, backend);
+    return ldr != null ? memoryCacheFactory.build(this, ldr) : memoryCacheFactory.build(this);
   }
 
   protected void checkNotFrozen() {
diff --git a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
index 558380d..fc55753 100644
--- a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
@@ -19,8 +19,7 @@
 import com.google.common.cache.LoadingCache;
 
 public interface MemoryCacheFactory {
-  <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend);
+  <K, V> Cache<K, V> build(CacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
+  <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
index 9553acc..e9b254b 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import java.io.IOException;
@@ -45,33 +46,31 @@
     this.config = config;
   }
 
-  protected abstract <K, V> Cache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, long diskLimit, CacheBackend backend);
+  protected abstract <K, V> Cache<K, V> buildImpl(PersistentCacheDef<K, V> in, long diskLimit);
 
   protected abstract <K, V> LoadingCache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit, CacheBackend backend);
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit);
 
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in) {
     long limit = getDiskLimit(in);
 
     if (isInMemoryCache(limit)) {
-      return memCacheFactory.build(in, backend);
+      return memCacheFactory.build(in);
     }
 
-    return buildImpl(in, limit, backend);
+    return buildImpl(in, limit);
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
+  public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> in, CacheLoader<K, V> loader) {
     long limit = getDiskLimit(in);
 
     if (isInMemoryCache(limit)) {
-      return memCacheFactory.build(in, loader, backend);
+      return memCacheFactory.build(in, loader);
     }
 
-    return buildImpl(in, loader, limit, backend);
+    return buildImpl(in, loader, limit);
   }
 
   private <K, V> long getDiskLimit(PersistentCacheDef<K, V> in) {
@@ -82,6 +81,7 @@
     return !diskEnabled || diskLimit <= 0;
   }
 
+  @Nullable
   private static Path getCacheDir(SitePaths site, String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
index 93f91ef..27fa9ca 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
@@ -19,10 +19,9 @@
 import com.google.common.cache.LoadingCache;
 
 public interface PersistentCacheFactory {
-  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def, CacheBackend backend);
+  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
+  <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader);
 
   void onStop(String plugin);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
index 4fc107f..59d66e3 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -30,7 +30,6 @@
 
 class PersistentCacheProvider<K, V> extends CacheProvider<K, V>
     implements Provider<Cache<K, V>>, PersistentCacheBinding<K, V>, PersistentCacheDef<K, V> {
-  private final CacheBackend backend;
   private int version;
   private long diskLimit;
   private CacheSerializer<K> keySerializer;
@@ -40,19 +39,9 @@
 
   PersistentCacheProvider(
       CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    this(module, name, keyType, valType, CacheBackend.CAFFEINE);
-  }
-
-  PersistentCacheProvider(
-      CacheModule module,
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType,
-      CacheBackend backend) {
-    super(module, name, keyType, valType, backend);
+    super(module, name, keyType, valType);
     version = -1;
     diskLimit = 128 << 20;
-    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -141,8 +130,8 @@
     freeze();
     CacheLoader<K, V> ldr = loader();
     return ldr != null
-        ? persistentCacheFactory.build(this, ldr, backend)
-        : persistentCacheFactory.build(this, backend);
+        ? persistentCacheFactory.build(this, ldr)
+        : persistentCacheFactory.build(this);
   }
 
   private static <T> void checkSerializer(
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index aa62745..b744058 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -47,6 +47,7 @@
     return source.refreshAfterWrite();
   }
 
+  @Nullable
   @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 16d62b3..445d8a0 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -21,7 +21,6 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
 import com.google.gerrit.server.cache.PersistentCacheBaseFactory;
 import com.google.gerrit.server.cache.PersistentCacheDef;
@@ -34,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.LinkedList;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
@@ -68,7 +67,7 @@
     super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
-    caches = new LinkedList<>();
+    caches = new ArrayList<>();
     this.cacheMap = cacheMap;
 
     if (diskEnabled) {
@@ -132,34 +131,28 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, long limit, CacheBackend backend) {
+  public <K, V> Cache<K, V> buildImpl(PersistentCacheDef<K, V> in, long limit) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
         new H2CacheImpl<>(
-            executor,
-            store,
-            def.keyType(),
-            (Cache<K, ValueHolder<V>>) memCacheFactory.build(def, backend));
+            executor, store, def.keyType(), (Cache<K, ValueHolder<V>>) memCacheFactory.build(def));
     synchronized (caches) {
       caches.add(cache);
     }
     return cache;
   }
 
-  @SuppressWarnings("unchecked")
+  @SuppressWarnings({"unchecked"})
   @Override
   public <K, V> LoadingCache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit, CacheBackend backend) {
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
         (Cache<K, ValueHolder<V>>)
             memCacheFactory.build(
-                def,
-                (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader),
-                backend);
+                def, (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader));
     H2CacheImpl<K, V> cache = new H2CacheImpl<>(executor, store, def.keyType(), mem);
     synchronized (caches) {
       caches.add(cache);
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 13b8b12..8327b88 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -103,6 +103,7 @@
     this.mem = mem;
   }
 
+  @Nullable
   @Override
   public V getIfPresent(Object objKey) {
     if (!keyType.getRawType().isInstance(objKey)) {
@@ -423,6 +424,7 @@
       return b == null || b.mightContain(key);
     }
 
+    @Nullable
     private BloomFilter<K> buildBloomFilter() {
       SqlHandle c = null;
       try {
@@ -472,6 +474,7 @@
       }
     }
 
+    @Nullable
     ValueHolder<V> getIfPresent(K key) {
       SqlHandle c = null;
       try {
@@ -548,7 +551,7 @@
         c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
       }
       try {
-        c.touch.setTimestamp(1, TimeUtil.nowTs());
+        c.touch.setTimestamp(1, new Timestamp(TimeUtil.nowMs()));
         keyType.set(c.touch, 2, key);
         c.touch.setInt(3, version);
         c.touch.executeUpdate();
@@ -581,7 +584,7 @@
           c.put.setBytes(2, valueSerializer.serialize(holder.value));
           c.put.setInt(3, version);
           c.put.setTimestamp(4, Timestamp.from(holder.created));
-          c.put.setTimestamp(5, TimeUtil.nowTs());
+          c.put.setTimestamp(5, new Timestamp(TimeUtil.nowMs()));
           c.put.executeUpdate();
           holder.clean = true;
         } finally {
@@ -717,6 +720,7 @@
       }
     }
 
+    @Nullable
     private SqlHandle close(SqlHandle h) {
       if (h != null) {
         h.close();
@@ -776,6 +780,7 @@
       }
     }
 
+    @Nullable
     private PreparedStatement closeStatement(PreparedStatement ps) {
       if (ps != null) {
         try {
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
index 591883e..1812043 100644
--- a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -47,7 +47,7 @@
 
   @Override
   public Funnel<K> funnel() {
-    return new Funnel<K>() {
+    return new Funnel<>() {
       private static final long serialVersionUID = 1L;
 
       @Override
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index 23caca7..28a2ede 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -23,12 +23,10 @@
 import com.github.benmanes.caffeine.guava.CaffeinatedGuava;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.RemovalNotification;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
@@ -51,34 +49,23 @@
   }
 
   @Override
-  public <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend) {
-    return backend.isLegacyBackend()
-        ? createLegacy(def).build()
-        : CaffeinatedGuava.build(create(def));
+  public <K, V> Cache<K, V> build(CacheDef<K, V> def) {
+    return CaffeinatedGuava.build(create(def));
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
-    return backend.isLegacyBackend()
-        ? createLegacy(def).build(loader)
+  public <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader) {
+    return cacheMaximumWeight(def) == 0
+        ? new PassthroughLoadingCache<>(loader)
         : CaffeinatedGuava.build(create(def), loader);
   }
 
-  @SuppressWarnings("unchecked")
-  private <K, V> CacheBuilder<K, V> createLegacy(CacheDef<K, V> def) {
-    CacheBuilder<K, V> builder = newLegacyCacheBuilder();
+  private <K, V> Caffeine<K, V> create(CacheDef<K, V> def) {
+    Caffeine<K, V> builder = newCacheBuilder();
     builder.recordStats();
-    builder.maximumWeight(
-        cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight()));
-
-    builder = builder.removalListener(forwardingRemovalListenerFactory.create(def.name()));
-
-    com.google.common.cache.Weigher<K, V> weigher = def.weigher();
-    if (weigher == null) {
-      weigher = unitWeight();
-    }
-    builder.weigher(weigher);
+    builder.maximumWeight(cacheMaximumWeight(def));
+    builder = builder.removalListener(newRemovalListener(def.name()));
+    builder.weigher(newWeigher(def.weigher()));
 
     Duration expireAfterWrite = def.expireAfterWrite();
     if (has(def.configKey(), "maxAge")) {
@@ -123,55 +110,8 @@
     return builder;
   }
 
-  private <K, V> Caffeine<K, V> create(CacheDef<K, V> def) {
-    Caffeine<K, V> builder = newCacheBuilder();
-    builder.recordStats();
-    builder.maximumWeight(
-        cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight()));
-    builder = builder.removalListener(newRemovalListener(def.name()));
-    builder.weigher(newWeigher(def.weigher()));
-
-    Duration expireAfterWrite = def.expireAfterWrite();
-    if (has(def.configKey(), "maxAge")) {
-      builder.expireAfterWrite(
-          ConfigUtil.getTimeUnit(
-              cfg, "cache", def.configKey(), "maxAge", toSeconds(expireAfterWrite), SECONDS),
-          SECONDS);
-    } else if (expireAfterWrite != null) {
-      builder.expireAfterWrite(expireAfterWrite.toNanos(), NANOSECONDS);
-    }
-
-    Duration expireAfterAccess = def.expireFromMemoryAfterAccess();
-    if (has(def.configKey(), "expireFromMemoryAfterAccess")) {
-      builder.expireAfterAccess(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "cache",
-              def.configKey(),
-              "expireFromMemoryAfterAccess",
-              toSeconds(expireAfterAccess),
-              SECONDS),
-          SECONDS);
-    } else if (expireAfterAccess != null) {
-      builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
-    }
-
-    Duration refreshAfterWrite = def.refreshAfterWrite();
-    if (has(def.configKey(), "refreshAfterWrite")) {
-      builder.expireAfterAccess(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "cache",
-              def.configKey(),
-              "refreshAfterWrite",
-              toSeconds(refreshAfterWrite),
-              SECONDS),
-          SECONDS);
-    } else if (refreshAfterWrite != null) {
-      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
-    }
-
-    return builder;
+  private <K, V> long cacheMaximumWeight(CacheDef<K, V> def) {
+    return cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight());
   }
 
   private static long toSeconds(@Nullable Duration duration) {
@@ -183,15 +123,6 @@
   }
 
   @SuppressWarnings("unchecked")
-  private static <K, V> CacheBuilder<K, V> newLegacyCacheBuilder() {
-    return (CacheBuilder<K, V>) CacheBuilder.newBuilder();
-  }
-
-  private static <K, V> com.google.common.cache.Weigher<K, V> unitWeight() {
-    return (key, value) -> 1;
-  }
-
-  @SuppressWarnings("unchecked")
   private static <K, V> Caffeine<K, V> newCacheBuilder() {
     return (Caffeine<K, V>) Caffeine.newBuilder();
   }
diff --git a/java/com/google/gerrit/server/cache/mem/PassthroughLoadingCache.java b/java/com/google/gerrit/server/cache/mem/PassthroughLoadingCache.java
new file mode 100644
index 0000000..ded21aa
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/mem/PassthroughLoadingCache.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.mem;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.CacheLoader.UnsupportedLoadingOperationException;
+import com.google.common.cache.CacheStats;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutionException;
+
+/** Implementation of a NOOP cache that just passes all gets to the loader */
+public class PassthroughLoadingCache<K, V> implements LoadingCache<K, V> {
+
+  private final CacheLoader<? super K, V> cacheLoader;
+
+  public PassthroughLoadingCache(CacheLoader<? super K, V> cacheLoader) {
+    this.cacheLoader = cacheLoader;
+  }
+
+  @Override
+  public @Nullable V getIfPresent(Object key) {
+    return null;
+  }
+
+  @Override
+  public V get(K key, Callable<? extends V> loader) throws ExecutionException {
+    try {
+      return loader.call();
+    } catch (Exception e) {
+      throw new ExecutionException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<K, V> getAllPresent(Iterable<?> keys) {
+    return ImmutableMap.of();
+  }
+
+  @Override
+  public void put(K key, V value) {}
+
+  @Override
+  public void putAll(Map<? extends K, ? extends V> m) {}
+
+  @Override
+  public void invalidate(Object key) {}
+
+  @Override
+  public void invalidateAll(Iterable<?> keys) {}
+
+  @Override
+  public void invalidateAll() {}
+
+  @Override
+  public long size() {
+    return 0;
+  }
+
+  @Override
+  public CacheStats stats() {
+    return new CacheStats(0, 0, 0, 0, 0, 0);
+  }
+
+  @Override
+  public void cleanUp() {}
+
+  @Override
+  public V get(K key) throws ExecutionException {
+    try {
+      return cacheLoader.load(key);
+    } catch (Exception e) {
+      throw new ExecutionException(e);
+    }
+  }
+
+  @Override
+  public V getUnchecked(K key) {
+    try {
+      return cacheLoader.load(key);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<K, V> getAll(Iterable<? extends K> keys) throws ExecutionException {
+    try {
+      try {
+        return getAllBulk(keys);
+      } catch (UnsupportedLoadingOperationException e) {
+        return getAllIndividually(keys);
+      }
+    } catch (Exception e) {
+      throw new ExecutionException(e);
+    }
+  }
+
+  private ImmutableMap<K, V> getAllIndividually(Iterable<? extends K> keys) throws Exception {
+    ImmutableMap.Builder<K, V> builder = ImmutableMap.builder();
+    for (K k : keys) {
+      builder.put(k, cacheLoader.load(k));
+    }
+    return builder.build();
+  }
+
+  @SuppressWarnings("unchecked")
+  private ImmutableMap<K, V> getAllBulk(Iterable<? extends K> keys) throws Exception {
+    return (ImmutableMap<K, V>) ImmutableMap.copyOf(cacheLoader.loadAll(keys));
+  }
+
+  @Override
+  public V apply(K key) {
+    return getUnchecked(key);
+  }
+
+  @Override
+  public void refresh(K key) {}
+
+  @Override
+  public ConcurrentMap<K, V> asMap() {
+    return new ConcurrentHashMap<>();
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 5377fc1..bb28a6d 100644
--- a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -31,7 +31,7 @@
    * @return serializer of type {@code T}.
    */
   static <T, D> CacheSerializer<T> convert(CacheSerializer<D> delegate, Converter<T, D> converter) {
-    return new CacheSerializer<T>() {
+    return new CacheSerializer<>() {
       @Override
       public byte[] serialize(T object) {
         return delegate.serialize(converter.convert(object));
diff --git a/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
index ee71846..57aea22 100644
--- a/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
@@ -42,7 +42,7 @@
     }
   }
 
-  @SuppressWarnings("unchecked")
+  @SuppressWarnings({"unchecked", "BanSerializableRead"})
   @Override
   public T deserialize(byte[] in) {
     Object object;
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
index 7449917..09f3543 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper to (de)serialize values for caches. */
 public class InternalGroupSerializer {
@@ -33,7 +33,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid(proto.getOwnerGroupUuid()))
             .setVisibleToAll(proto.getIsVisibleToAll())
             .setGroupUUID(AccountGroup.uuid(proto.getGroupUuid()))
-            .setCreatedOn(new Timestamp(proto.getCreatedOn()))
+            .setCreatedOn(Instant.ofEpochMilli(proto.getCreatedOn()))
             .setMembers(
                 proto.getMembersIdsList().stream()
                     .map(a -> Account.id(a))
@@ -62,7 +62,7 @@
             .setOwnerGroupUuid(autoValue.getOwnerGroupUUID().get())
             .setIsVisibleToAll(autoValue.isVisibleToAll())
             .setGroupUuid(autoValue.getGroupUUID().get())
-            .setCreatedOn(autoValue.getCreatedOn().getTime());
+            .setCreatedOn(autoValue.getCreatedOn().toEpochMilli());
 
     autoValue.getMembers().stream().forEach(m -> builder.addMembersIds(m.get()));
     autoValue.getSubgroups().stream().forEach(s -> builder.addSubgroupUuids(s.get()));
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index c00961f..eb3949e 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Optional;
 
 /** Helper to (de)serialize values for caches. */
 public class LabelTypeSerializer {
@@ -36,24 +37,12 @@
             proto.getValuesList().stream()
                 .map(LabelValueSerializer::deserialize)
                 .collect(toImmutableList()))
+        .setDescription(Optional.of(proto.getDescription()))
         .setFunction(FUNCTION_CONVERTER.convert(proto.getFunction()))
         .setAllowPostSubmit(proto.getAllowPostSubmit())
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
         .setCopyCondition(Strings.emptyToNull(proto.getCopyCondition()))
-        .setCopyAnyScore(proto.getCopyAnyScore())
-        .setCopyMinScore(proto.getCopyMinScore())
-        .setCopyMaxScore(proto.getCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            proto.getCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(proto.getCopyAllScoresIfNoChange())
-        .setCopyValues(
-            proto.getCopyValuesList().stream()
-                .map(Shorts::saturatedCast)
-                .collect(toImmutableList()))
         .setMaxNegative(Shorts.saturatedCast(proto.getMaxNegative()))
         .setMaxPositive(Shorts.saturatedCast(proto.getMaxPositive()))
         .setRefPatterns(proto.getRefPatternsList())
@@ -68,20 +57,9 @@
             autoValue.getValues().stream()
                 .map(LabelValueSerializer::serialize)
                 .collect(toImmutableList()))
+        .setDescription(autoValue.getDescription().orElse(""))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
         .setCopyCondition(autoValue.getCopyCondition().orElse(""))
-        .setCopyAnyScore(autoValue.isCopyAnyScore())
-        .setCopyMinScore(autoValue.isCopyMinScore())
-        .setCopyMaxScore(autoValue.isCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            autoValue.isCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(
-            autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(autoValue.isCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(autoValue.isCopyAllScoresIfNoChange())
-        .addAllCopyValues(
-            autoValue.getCopyValues().stream().map(c -> (int) c).collect(toImmutableList()))
         .setAllowPostSubmit(autoValue.isAllowPostSubmit())
         .setIgnoreSelfApproval(autoValue.isIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(autoValue.getDefaultValue()))
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
index a7a84f7..5ac9ac4 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -27,7 +27,9 @@
     return StoredCommentLinkInfo.builder(proto.getName())
         .setMatch(emptyToNull(proto.getMatch()))
         .setLink(emptyToNull(proto.getLink()))
-        .setHtml(emptyToNull(proto.getHtml()))
+        .setPrefix(emptyToNull(proto.getPrefix()))
+        .setSuffix(emptyToNull(proto.getSuffix()))
+        .setText(emptyToNull(proto.getText()))
         .setEnabled(proto.getEnabled())
         .setOverrideOnly(proto.getOverrideOnly())
         .build();
@@ -38,7 +40,9 @@
         .setName(autoValue.getName())
         .setMatch(nullToEmpty(autoValue.getMatch()))
         .setLink(nullToEmpty(autoValue.getLink()))
-        .setHtml(nullToEmpty(autoValue.getHtml()))
+        .setPrefix(nullToEmpty(autoValue.getPrefix()))
+        .setSuffix(nullToEmpty(autoValue.getSuffix()))
+        .setText(nullToEmpty(autoValue.getText()))
         .setEnabled(Optional.ofNullable(autoValue.getEnabled()).orElse(true))
         .setOverrideOnly(autoValue.getOverrideOnly())
         .build();
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
index 4e997b4..f61e261 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -14,32 +14,48 @@
 
 package com.google.gerrit.server.cache.serialize.entities;
 
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import java.util.Optional;
 
 /**
  * Serializer of a {@link SubmitRequirementExpressionResult} to {@link
  * SubmitRequirementExpressionResultProto}.
  */
 public class SubmitRequirementExpressionResultSerializer {
+
+  private static final Converter<String, SubmitRequirementExpressionResult.Status>
+      STATUS_CONVERTER = Enums.stringConverter(SubmitRequirementExpressionResult.Status.class);
+
   public static SubmitRequirementExpressionResult deserialize(
       SubmitRequirementExpressionResultProto proto) {
+    SubmitRequirementExpressionResult.Status status;
+    try {
+      status = STATUS_CONVERTER.convert(proto.getStatus());
+    } catch (IllegalArgumentException e) {
+      status = SubmitRequirementExpressionResult.Status.ERROR;
+    }
     return SubmitRequirementExpressionResult.create(
         SubmitRequirementExpression.create(proto.getExpression()),
-        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        status,
         proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
-        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()));
+        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()),
+        Optional.ofNullable(Strings.emptyToNull(proto.getErrorMessage())));
   }
 
   public static SubmitRequirementExpressionResultProto serialize(
       SubmitRequirementExpressionResult r) {
     return SubmitRequirementExpressionResultProto.newBuilder()
         .setExpression(r.expression().expressionString())
-        .setStatus(r.status().name())
+        .setStatus(STATUS_CONVERTER.reverse().convert(r.status()))
         .addAllPassingAtoms(r.passingAtoms())
         .addAllFailingAtoms(r.failingAtoms())
+        .setErrorMessage(r.errorMessage().orElse(""))
         .build();
   }
 }
diff --git a/java/com/google/gerrit/server/cancellation/BUILD b/java/com/google/gerrit/server/cancellation/BUILD
index 05530a5..40557b1 100644
--- a/java/com/google/gerrit/server/cancellation/BUILD
+++ b/java/com/google/gerrit/server/cancellation/BUILD
@@ -9,6 +9,6 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
-        "//lib/commons:lang",
+        "//lib/commons:text",
     ],
 )
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
index d89701f..dc01567 100644
--- a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -17,7 +17,7 @@
 import com.google.common.base.Throwables;
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
-import org.apache.commons.lang.WordUtils;
+import org.apache.commons.text.WordUtils;
 
 /** Exception to signal that the current request is cancelled and should be aborted. */
 public class RequestCancelledException extends RuntimeException {
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index d030ec1..59b32c2 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -40,10 +40,7 @@
 
   private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  // Provider is needed, because AbandonUtil is singleton, but ChangeQueryBuilder accesses
-  // index collection, that is only provided when multiversion index module is started.
-  // TODO(davido); Remove provider again, when support for legacy numeric fields is removed.
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final BatchAbandon batchAbandon;
   private final InternalUser internalUser;
 
@@ -52,11 +49,11 @@
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       BatchAbandon batchAbandon) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
@@ -74,11 +71,7 @@
       }
 
       List<ChangeData> changesToAbandon =
-          queryProvider
-              .get()
-              .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(query))
-              .entities();
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
@@ -99,7 +92,7 @@
             msg.append(" ").append(change.getId().get());
           }
           msg.append(".");
-          logger.atSevere().withCause(e).log(msg.toString());
+          logger.atSevere().withCause(e).log("%s", msg);
         }
       }
       logger.atInfo().log("Auto-Abandoned %d of %d changes.", count, changesToAbandon.size());
@@ -118,7 +111,7 @@
           queryProvider
               .get()
               .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(newQuery))
+              .query(queryBuilder.parse(newQuery))
               .entities();
       if (!changesToAbandon.isEmpty()) {
         validChanges.add(cd);
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 54ebf40..ed6d53d 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -106,6 +106,7 @@
     to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
   }
 
+  @Nullable
   private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     if (visitors.isEmpty()) {
       return null;
@@ -118,6 +119,10 @@
     copy.topic = changeInfo.topic;
     copy.attentionSet =
         changeInfo.attentionSet == null ? null : ImmutableMap.copyOf(changeInfo.attentionSet);
+    copy.removedFromAttentionSet =
+        changeInfo.removedFromAttentionSet == null
+            ? null
+            : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
     copy.assignee = changeInfo.assignee;
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
@@ -148,6 +153,7 @@
     return copy;
   }
 
+  @Nullable
   private RevisionInfo copy(List<ActionVisitor> visitors, RevisionInfo revisionInfo) {
     if (visitors.isEmpty()) {
       return null;
@@ -202,7 +208,7 @@
     return out;
   }
 
-  private Map<String, ActionInfo> toActionMap(
+  private ImmutableMap<String, ActionInfo> toActionMap(
       RevisionResource rsrc,
       List<ActionVisitor> visitors,
       ChangeInfo changeInfo,
@@ -222,6 +228,6 @@
       }
       out.put(d.getId(), actionInfo);
     }
-    return out;
+    return ImmutableMap.copyOf(out);
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index a980c32..ec90bec 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -31,18 +29,14 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 
 /** Add a specified user to the attention set. */
 public class AddToAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -63,14 +57,12 @@
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
       AddToAttentionSetSender.Factory addToAttentionSetSender,
-      MessageIdGenerator messageIdGenerator,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
     this.addToAttentionSetSender = addToAttentionSetSender;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
 
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -93,8 +85,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(
             attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
     return true;
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
index 6c6c765..e27a1a7 100644
--- a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
+++ b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
@@ -22,7 +22,7 @@
 /** REST resource that represents an entry in the attention set of a change. */
 public class AttentionSetEntryResource implements RestResource {
   public static final TypeLiteral<RestView<AttentionSetEntryResource>> ATTENTION_SET_ENTRY_KIND =
-      new TypeLiteral<RestView<AttentionSetEntryResource>>() {};
+      new TypeLiteral<>() {};
 
   public interface Factory {
     AttentionSetEntryResource create(ChangeResource change, Account.Id id);
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index 9a3c388..2efa027 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -68,7 +68,7 @@
       return;
     }
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
-    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.now())) {
       u.setNotify(notify);
       for (ChangeData change : changes) {
         if (!project.equals(change.project())) {
@@ -78,7 +78,9 @@
                   change.project().get(), project.get()));
         }
         u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
-        u.addOp(change.getId(), storeSubmitRequirementsOpFactory.create());
+        u.addOp(
+            change.getId(),
+            storeSubmitRequirementsOpFactory.create(change.submitRequirements().values(), change));
       }
       u.execute();
 
diff --git a/java/com/google/gerrit/server/change/ChangeEditResource.java b/java/com/google/gerrit/server/change/ChangeEditResource.java
index 392709e..67edb56 100644
--- a/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -31,7 +31,7 @@
  */
 public class ChangeEditResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeEditResource>> CHANGE_EDIT_KIND =
-      new TypeLiteral<RestView<ChangeEditResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ChangeResource change;
   private final ChangeEdit edit;
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index c8001bb..8773bb7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -29,7 +29,10 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -242,32 +245,38 @@
     return change;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setTopic(String topic) {
     checkState(change == null, "setTopic(String) only valid before creating change");
     this.topic = topic;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setCherryPickOf(PatchSet.Id cherryPickOf) {
     this.cherryPickOf = cherryPickOf;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setMessage(String message) {
     this.message = message;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setPatchSetDescription(String patchSetDescription) {
     this.patchSetDescription = patchSetDescription;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setReviewersAndCcs(
       Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
     return setReviewersAndCcsAsStrings(
@@ -275,35 +284,57 @@
         Iterables.transform(ccs, Account.Id::toString));
   }
 
+  @CanIgnoreReturnValue
+  public ChangeInserter setReviewersAndCcsIgnoreVisibility(
+      Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
+    return setReviewersAndCcsAsStrings(
+        Iterables.transform(reviewers, Account.Id::toString),
+        Iterables.transform(ccs, Account.Id::toString),
+        /* skipVisibilityCheck= */ true);
+  }
+
+  @CanIgnoreReturnValue
   public ChangeInserter setReviewersAndCcsAsStrings(
       Iterable<String> reviewers, Iterable<String> ccs) {
+    return setReviewersAndCcsAsStrings(reviewers, ccs, /* skipVisibilityCheck= */ false);
+  }
+
+  @CanIgnoreReturnValue
+  private ChangeInserter setReviewersAndCcsAsStrings(
+      Iterable<String> reviewers, Iterable<String> ccs, boolean skipVisibilityCheck) {
     reviewerInputs =
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
-                Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
+                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER, skipVisibilityCheck)),
+                Streams.stream(ccs)
+                    .distinct()
+                    .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck)))
             .collect(toImmutableList());
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setPrivate(boolean isPrivate) {
     checkState(change == null, "setPrivate(boolean) only valid before creating change");
     this.isPrivate = isPrivate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setStatus(Change.Status status) {
     checkState(change == null, "setStatus(Change.Status) only valid before creating change");
     this.status = status;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setGroups(List<String> groups) {
     requireNonNull(groups, "groups may not be empty");
     checkState(patchSet == null, "setGroups(List<String>) only valid before creating change");
@@ -311,8 +342,10 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
     checkState(
         patchSet == null,
         "setValidationOptions(ImmutableListMultimap<String, String>) only valid before creating a"
@@ -321,21 +354,25 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setSendMail(boolean sendMail) {
     this.sendMail = sendMail;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setRequestScopePropagator(RequestScopePropagator r) {
     this.requestScopePropagator = r;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setRevertOf(Change.Id revertOf) {
     this.revertOf = revertOf;
     return this;
@@ -350,6 +387,7 @@
     return patchSet;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setApprovals(Map<String, Short> approvals) {
     this.approvals = approvals;
     return this;
@@ -367,11 +405,13 @@
    * @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
    */
   @Deprecated
+  @CanIgnoreReturnValue
   public ChangeInserter setUpdateRef(boolean updateRef) {
     this.updateRef = updateRef;
     return this;
   }
 
+  @Nullable
   public String getChangeMessage() {
     if (message == null) {
       return null;
@@ -466,10 +506,10 @@
     approvalsUtil.addApprovalsForNewPatchSet(
         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
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
-    // TODO(dborowitz): Still necessary?
     if (!approvals.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
@@ -485,7 +525,7 @@
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (sendMail && notify.shouldNotify()) {
+    if (sendMail) {
       Runnable sender =
           new Runnable() {
             @Override
@@ -497,15 +537,15 @@
                 emailSender.setPatchSet(patchSet, patchSetInfo);
                 emailSender.setNotify(notify);
                 emailSender.addReviewers(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
                 emailSender.addReviewersByEmail(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
                 emailSender.addExtraCC(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
                 emailSender.addExtraCCByEmail(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
                 emailSender.setMessageId(
                     messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
                 emailSender.send();
@@ -594,7 +634,8 @@
     }
   }
 
-  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(
+      String reviewer, ReviewerState state, boolean skipVisibilityCheck) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
     InternalReviewerInput input =
@@ -605,12 +646,17 @@
     // 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 = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+
+    input.skipVisibilityCheck = skipVisibilityCheck;
 
     return input;
   }
 
   private ImmutableList<InternalReviewerInput> getReviewerInputs() {
+    if (projectState.is(BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS)) {
+      return reviewerInputs;
+    }
     return Streams.concat(
             reviewerInputs.stream(),
             Streams.stream(
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 328c5de..912d202 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -35,14 +35,17 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+import static com.google.gerrit.server.util.AttentionSetUtil.removalsOnly;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -60,7 +63,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -99,8 +101,6 @@
 import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -146,7 +146,7 @@
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
 
-  static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
       ImmutableSet.of(
           ALL_COMMITS,
           ALL_REVISIONS,
@@ -236,6 +236,7 @@
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
+  private final boolean cacheQueryResultsByChangeNum;
 
   private AccountLoader accountLoader;
   private FixInput fix;
@@ -255,7 +256,6 @@
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
-      ExperimentFeatures experimentFeatures,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
@@ -274,10 +274,10 @@
     this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
-    this.lazyLoad =
-        containsAnyOf(this.options, REQUIRE_LAZY_LOAD)
-            || lazyloadSubmitRequirements(this.options, experimentFeatures);
+    this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
+    this.cacheQueryResultsByChangeNum =
+        cfg.getBoolean("index", "cacheQueryResultsByChangeNum", true);
 
     logger.atFine().log("options = %s", options);
   }
@@ -382,10 +382,10 @@
 
   private Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
     Collection<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
-    Map<SubmitRequirement, SubmitRequirementResult> requirements = cd.submitRequirements();
-    for (Map.Entry<SubmitRequirement, SubmitRequirementResult> entry : requirements.entrySet()) {
-      reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue()));
-    }
+    cd.submitRequirementsIncludingLegacy().entrySet().stream()
+        .filter(entry -> !entry.getValue().isHidden())
+        .forEach(
+            entry -> reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
     return reqInfos;
   }
 
@@ -500,7 +500,7 @@
         // This problem has two sides where 'last in the list' has to be respected:
         // (1) Caching
         // (2) Reusing
-        boolean isCacheable = i != changes.size() - 1;
+        boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
         ChangeData cd = changes.get(i);
         ChangeInfo info = cache.get(cd.getId());
         if (info != null && isCacheable) {
@@ -556,8 +556,8 @@
       info.subject = c.getSubject();
       info.status = c.getStatus().asChangeStatus();
       info.owner = new AccountInfo(c.getOwner().get());
-      info.created = c.getCreatedOn();
-      info.updated = c.getLastUpdatedOn();
+      info.setCreated(c.getCreatedOn());
+      info.setUpdated(c.getLastUpdatedOn());
       info._number = c.getId().get();
       info.problems = result.problems();
       info.isPrivate = c.isPrivate() ? true : null;
@@ -603,6 +603,12 @@
     out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
     if (!cd.attentionSet().isEmpty()) {
+      out.removedFromAttentionSet =
+          removalsOnly(cd.attentionSet()).stream()
+              .collect(
+                  toImmutableMap(
+                      a -> a.account().get(),
+                      a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
       out.attentionSet =
           // This filtering should match GetAttentionSet.
           additionsOnly(cd.attentionSet()).stream()
@@ -639,8 +645,8 @@
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
     out.owner = accountLoader.get(in.getOwner());
-    out.created = in.getCreatedOn();
-    out.updated = in.getLastUpdatedOn();
+    out.setCreated(in.getCreatedOn());
+    out.setUpdated(in.getLastUpdatedOn());
     out._number = in.getId().get();
     out.totalCommentCount = cd.totalCommentCount();
     out.unresolvedCommentCount = cd.unresolvedCommentCount();
@@ -682,6 +688,7 @@
             !cd.change().isAbandoned()
                 ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
+        out.removableLabels = labelsJson.removableLabels(accountLoader, user, cd);
       }
     }
 
@@ -772,18 +779,20 @@
     List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
     List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
     for (ReviewerStatusUpdate c : reviewerUpdates) {
-      ReviewerUpdateInfo change = new ReviewerUpdateInfo();
-      change.updated = c.date();
-      change.state = c.state().asReviewerState();
-      change.updatedBy = accountLoader.get(c.updatedBy());
-      change.reviewer = accountLoader.get(c.reviewer());
+      ReviewerUpdateInfo change =
+          new ReviewerUpdateInfo(
+              c.date(),
+              accountLoader.get(c.updatedBy()),
+              accountLoader.get(c.reviewer()),
+              c.state().asReviewerState());
       result.add(change);
     }
     return result;
   }
 
   private boolean submittable(ChangeData cd) {
-    return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
+    return cd.submitRequirementsIncludingLegacy().values().stream()
+        .allMatch(SubmitRequirementResult::fulfilled);
   }
 
   private void setSubmitter(ChangeData cd, ChangeInfo out) {
@@ -791,21 +800,20 @@
     if (!s.isPresent()) {
       return;
     }
-    out.submitted = s.get().granted();
-    out.submitter = accountLoader.get(s.get().accountId());
+    out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
   }
 
-  private Collection<ChangeMessageInfo> messages(ChangeData cd) {
+  private ImmutableList<ChangeMessageInfo> messages(ChangeData cd) {
     List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
     if (messages.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
     for (ChangeMessage message : messages) {
       result.add(createChangeMessageInfo(message, accountLoader));
     }
-    return result;
+    return ImmutableList.copyOf(result);
   }
 
   private Collection<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
@@ -925,11 +933,12 @@
       }
       src = Collections.singletonList(ps);
     }
-    Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
+    // Sort by patch set ID in increasing order to have a stable output.
+    ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
     for (PatchSet patchSet : src) {
       map.put(patchSet.id(), patchSet);
     }
-    return map;
+    return map.build();
   }
 
   private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
@@ -943,20 +952,4 @@
     }
     return ImmutableListMultimap.of();
   }
-
-  private static boolean lazyloadSubmitRequirements(
-      Set<ListChangesOption> changeOptions, ExperimentFeatures experimentFeatures) {
-    // TODO(ghareeb,hiesel): Remove this method.
-    // We are testing the new submit requirements with users in lieu of upgrading the change index
-    // to a version that supports the new requirements.
-    // Upgrading now, before the feature is finalized would be counter productive, because the index
-    // format might change while we iterate over the feature.
-    // Allowing changes to lazyload parameters will slow down dashboards for users who have this
-    // feature enabled, but will backfill submit requirements that weren't loaded from the index by
-    // simply computing them.
-    return changeOptions.contains(SUBMIT_REQUIREMENTS)
-        && experimentFeatures.isFeatureEnabled(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD);
-  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeMessageResource.java b/java/com/google/gerrit/server/change/ChangeMessageResource.java
index 25f952d..751faa0 100644
--- a/java/com/google/gerrit/server/change/ChangeMessageResource.java
+++ b/java/com/google/gerrit/server/change/ChangeMessageResource.java
@@ -23,7 +23,7 @@
 /** A change message resource. */
 public class ChangeMessageResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeMessageResource>> CHANGE_MESSAGE_KIND =
-      new TypeLiteral<RestView<ChangeMessageResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ChangeResource changeResource;
   private final ChangeMessageInfo changeMessage;
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 970f1b5..919586e 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -62,8 +62,7 @@
    */
   public static final int JSON_FORMAT_VERSION = 1;
 
-  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND =
-      new TypeLiteral<RestView<ChangeResource>>() {};
+  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND = new TypeLiteral<>() {};
 
   public interface Factory {
     ChangeResource create(ChangeNotes notes, CurrentUser user);
@@ -166,7 +165,7 @@
   // unrelated to the UI.
   public void prepareETag(Hasher h, CurrentUser user) {
     h.putInt(JSON_FORMAT_VERSION)
-        .putLong(getChange().getLastUpdatedOn().getTime())
+        .putLong(getChange().getLastUpdatedOn().toEpochMilli())
         .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 7d0bda1..38efc44 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -95,7 +95,7 @@
   public abstract static class Result {
     private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
       return new AutoValue_ConsistencyChecker_Result(
-          notes.getChangeId(), notes.getChange(), problems);
+          notes.getChangeId(), notes.getChange(), ImmutableList.copyOf(problems));
     }
 
     public abstract Change.Id id();
@@ -103,7 +103,7 @@
     @Nullable
     public abstract Change change();
 
-    public abstract List<ProblemInfo> problems();
+    public abstract ImmutableList<ProblemInfo> problems();
   }
 
   private final ChangeNotes.Factory notesFactory;
@@ -626,7 +626,7 @@
   }
 
   private BatchUpdate newBatchUpdate() {
-    return updateFactory.create(change().getProject(), user.get(), TimeUtil.nowTs());
+    return updateFactory.create(change().getProject(), user.get(), TimeUtil.now());
   }
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
@@ -764,6 +764,7 @@
     return serverIdent.get();
   }
 
+  @Nullable
   private RevCommit parseCommit(ObjectId objId, String desc) {
     try {
       return rw.parseCommit(objId);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index b512a2d..f3fd68e 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -76,9 +76,6 @@
     if (sendEmail) {
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (!notify.shouldNotify()) {
-          return;
-        }
         DeleteReviewerSender emailSender =
             deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 1e40429..afb9d76 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -193,10 +193,13 @@
         notify = notify.withHandling(NotifyHandling.OWNER);
       }
       try {
-        if (notify.shouldNotify()) {
-          emailReviewers(
-              ctx.getProject(), currChange, mailMessage, ctx.getWhen(), notify, ctx.getRepoView());
-        }
+        emailReviewers(
+            ctx.getProject(),
+            currChange,
+            mailMessage,
+            Timestamp.from(ctx.getWhen()),
+            notify,
+            ctx.getRepoView());
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot email update for change %s", currChange.getId());
@@ -223,7 +226,7 @@
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
-    approvals = approvalsUtil.byChange(ctx.getNotes()).values();
+    approvals = ctx.getNotes().getApprovals().all().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
@@ -251,7 +254,7 @@
         deleteReviewerSenderFactory.create(projectName, change.getId());
     emailSender.setFrom(userId);
     emailSender.addReviewers(Collections.singleton(reviewer.id()));
-    emailSender.setChangeMessage(mailMessage, timestamp);
+    emailSender.setChangeMessage(mailMessage, timestamp.toInstant());
     emailSender.setNotify(notify);
     emailSender.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index 19a495d..2e40f2c 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -25,7 +25,7 @@
 
 public class DraftCommentResource implements RestResource {
   public static final TypeLiteral<RestView<DraftCommentResource>> DRAFT_COMMENT_KIND =
-      new TypeLiteral<RestView<DraftCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final HumanComment comment;
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
new file mode 100644
index 0000000..f67ce4a
--- /dev/null
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -0,0 +1,242 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class EmailNewPatchSet {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    EmailNewPatchSet create(
+        PostUpdateContext postUpdateContext,
+        PatchSet patchSet,
+        @Nullable String message,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+        @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId);
+  }
+
+  private final ExecutorService sendEmailExecutor;
+  private final ThreadLocalRequestContext threadLocalRequestContext;
+  private final AsyncSender asyncSender;
+
+  private RequestScopePropagator requestScopePropagator;
+
+  @Inject
+  EmailNewPatchSet(
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ThreadLocalRequestContext threadLocalRequestContext,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      PatchSetInfoFactory patchSetInfoFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted PostUpdateContext postUpdateContext,
+      @Assisted PatchSet patchSet,
+      @Nullable @Assisted String message,
+      @Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
+      @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+      @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId) {
+    this.sendEmailExecutor = sendEmailExecutor;
+    this.threadLocalRequestContext = threadLocalRequestContext;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReplacePatchSet");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            postUpdateContext.getIdentifiedUser(),
+            replacePatchSetFactory,
+            patchSetInfoFactory,
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            message,
+            postUpdateContext.getWhen(),
+            outdatedApprovals,
+            reviewers,
+            extraCcs,
+            changeKind,
+            preUpdateMetaId,
+            postUpdateSubmitRequirementResults);
+  }
+
+  public EmailNewPatchSet setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator != null
+                ? requestScopePropagator.wrap(asyncSender)
+                : () -> {
+                  RequestContext old = threadLocalRequestContext.setContext(asyncSender);
+                  try {
+                    asyncSender.run();
+                  } finally {
+                    threadLocalRequestContext.setContext(old);
+                  }
+                });
+  }
+
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final IdentifiedUser user;
+    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableSet<PatchSetApproval> outdatedApprovals;
+    private final ImmutableSet<Account.Id> reviewers;
+    private final ImmutableSet<Account.Id> extraCcs;
+    private final ChangeKind changeKind;
+    private final ObjectId preUpdateMetaId;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        IdentifiedUser user,
+        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        String message,
+        Instant timestamp,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        ImmutableSet<Account.Id> reviewers,
+        ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.user = user;
+      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.outdatedApprovals = outdatedApprovals;
+      this.reviewers = reviewers;
+      this.extraCcs = extraCcs;
+      this.changeKind = changeKind;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender emailSender =
+            replacePatchSetFactory.create(
+                projectName,
+                changeId,
+                changeKind,
+                preUpdateMetaId,
+                postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setNotify(notify);
+        emailSender.addReviewers(reviewers);
+        emailSender.addExtraCC(extraCcs);
+        emailSender.addOutdatedApproval(outdatedApprovals);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 3c7ea44..a9886c7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -16,29 +16,38 @@
 
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
 
-public class EmailReviewComments implements Runnable, RequestContext {
+public class EmailReviewComments {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -47,13 +56,11 @@
     /**
      * Creates handle for sending email
      *
-     * @param notify setting for handling notification.
-     * @param notes change notes.
+     * @param postUpdateContext the post update context from the calling BatchUpdateOp
      * @param patchSet patch set corresponding to the top-level op
-     * @param user user the email should come from.
+     * @param preUpdateMetaId the SHA1 to which the notes branch pointed before the update
      * @param message used by text template only. The contents of this message typically include the
      *     "Patch set N" header and "(M comments)".
-     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
@@ -61,34 +68,17 @@
      * @param labels labels applied as part of this review operation.
      */
     EmailReviewComments create(
-        NotifyResolver.Result notify,
-        ChangeNotes notes,
+        PostUpdateContext postUpdateContext,
         PatchSet patchSet,
-        IdentifiedUser user,
+        ObjectId preUpdateMetaId,
         @Assisted("message") String message,
-        Timestamp timestamp,
         List<? extends Comment> comments,
-        @Assisted("patchSetComment") String patchSetComment,
-        List<LabelVote> labels,
-        RepoView repoView);
+        @Nullable @Assisted("patchSetComment") String patchSetComment,
+        List<LabelVote> labels);
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final CommentSender.Factory commentSenderFactory;
-  private final ThreadLocalRequestContext requestContext;
-  private final MessageIdGenerator messageIdGenerator;
-
-  private final NotifyResolver.Result notify;
-  private final ChangeNotes notes;
-  private final PatchSet patchSet;
-  private final IdentifiedUser user;
-  private final String message;
-  private final Timestamp timestamp;
-  private final List<? extends Comment> comments;
-  private final String patchSetComment;
-  private final List<LabelVote> labels;
-  private final RepoView repoView;
+  private final AsyncSender asyncSender;
 
   @Inject
   EmailReviewComments(
@@ -97,69 +87,151 @@
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
-      @Assisted NotifyResolver.Result notify,
-      @Assisted ChangeNotes notes,
+      @Assisted PostUpdateContext postUpdateContext,
       @Assisted PatchSet patchSet,
-      @Assisted IdentifiedUser user,
+      @Assisted ObjectId preUpdateMetaId,
       @Assisted("message") String message,
-      @Assisted Timestamp timestamp,
       @Assisted List<? extends Comment> comments,
       @Nullable @Assisted("patchSetComment") String patchSetComment,
-      @Assisted List<LabelVote> labels,
-      @Assisted RepoView repoView) {
+      @Assisted List<LabelVote> labels) {
     this.sendEmailsExecutor = executor;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.commentSenderFactory = commentSenderFactory;
-    this.requestContext = requestContext;
-    this.messageIdGenerator = messageIdGenerator;
-    this.notify = notify;
-    this.notes = notes;
-    this.patchSet = patchSet;
-    this.user = user;
-    this.message = message;
-    this.timestamp = timestamp;
-    this.comments = COMMENT_ORDER.sortedCopy(comments);
-    this.patchSetComment = patchSetComment;
-    this.labels = labels;
-    this.repoView = repoView;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReviewComments");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            commentSenderFactory,
+            patchSetInfoFactory,
+            postUpdateContext.getUser().asIdentifiedUser(),
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            preUpdateMetaId,
+            message,
+            postUpdateContext.getWhen(),
+            ImmutableList.copyOf(COMMENT_ORDER.sortedCopy(comments)),
+            patchSetComment,
+            ImmutableList.copyOf(labels),
+            postUpdateSubmitRequirementResults);
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    RequestContext old = requestContext.setContext(this);
-    try {
-      CommentSender emailSender =
-          commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
-      emailSender.setFrom(user.getAccountId());
-      emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message, timestamp);
-      emailSender.setComments(comments);
-      emailSender.setPatchSetComment(patchSetComment);
-      emailSender.setLabels(labels);
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdateAndReason(
-              repoView, patchSet.id(), "EmailReviewComments"));
-      emailSender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
-    } finally {
-      requestContext.setContext(old);
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  // TODO: The passed in Comment class is not thread-safe, replace it with an AutoValue type.
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final CommentSender.Factory commentSenderFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final IdentifiedUser user;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final ObjectId preUpdateMetaId;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableList<? extends Comment> comments;
+    @Nullable private final String patchSetComment;
+    private final ImmutableList<LabelVote> labels;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        CommentSender.Factory commentSenderFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        IdentifiedUser user,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        ObjectId preUpdateMetaId,
+        String message,
+        Instant timestamp,
+        ImmutableList<? extends Comment> comments,
+        @Nullable String patchSetComment,
+        ImmutableList<LabelVote> labels,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.requestContext = requestContext;
+      this.commentSenderFactory = commentSenderFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.user = user;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.comments = comments;
+      this.patchSetComment = patchSetComment;
+      this.labels = labels;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        CommentSender emailSender =
+            commentSenderFactory.create(
+                projectName, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setComments(comments);
+        emailSender.setPatchSetComment(patchSetComment);
+        emailSender.setLabels(labels);
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return user.getRealUser();
+    @Override
+    public String toString() {
+      return "send-email comments";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index b729c11..d9c30d7 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -41,6 +42,7 @@
     this.diffs = diffOperations;
   }
 
+  @Nullable
   @Override
   public Map<String, FileInfo> getFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
@@ -51,22 +53,25 @@
         // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
             diffs.listModifiedFilesAgainstParent(
-                change.getProject(), objectId, /* parentNum= */ 0));
+                change.getProject(), objectId, /* parentNum= */ 0, DiffOptions.DEFAULTS));
       }
-      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
+      return asFileInfo(
+          diffs.listModifiedFiles(
+              change.getProject(), base.commitId(), objectId, DiffOptions.DEFAULTS));
     } catch (DiffNotAvailableException e) {
       convertException(e);
       return null; // unreachable. handleAndThrow will throw an exception anyway
     }
   }
 
+  @Nullable
   @Override
   public Map<String, FileInfo> getFileInfoMap(
       Project.NameKey project, ObjectId objectId, int parent)
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.listModifiedFilesAgainstParent(project, objectId, parent);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent, DiffOptions.DEFAULTS);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
@@ -99,6 +104,14 @@
       fileInfo.oldPath = FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType());
       fileInfo.sizeDelta = fileDiff.sizeDelta();
       fileInfo.size = fileDiff.size();
+      fileInfo.oldMode =
+          fileDiff.oldMode().isPresent() && !fileDiff.oldMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.oldMode().get().getMode()
+              : null;
+      fileInfo.newMode =
+          fileDiff.newMode().isPresent() && !fileDiff.newMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.newMode().get().getMode()
+              : null;
       if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
         fileInfo.binary = true;
       } else {
diff --git a/java/com/google/gerrit/server/change/FileResource.java b/java/com/google/gerrit/server/change/FileResource.java
index 5402338..22fe39c 100644
--- a/java/com/google/gerrit/server/change/FileResource.java
+++ b/java/com/google/gerrit/server/change/FileResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class FileResource implements RestResource {
-  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
-      new TypeLiteral<RestView<FileResource>>() {};
+  public static final TypeLiteral<RestView<FileResource>> FILE_KIND = new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final Patch.Key key;
diff --git a/java/com/google/gerrit/server/change/FixResource.java b/java/com/google/gerrit/server/change/FixResource.java
index b6b5894..0c5ee23 100644
--- a/java/com/google/gerrit/server/change/FixResource.java
+++ b/java/com/google/gerrit/server/change/FixResource.java
@@ -21,8 +21,7 @@
 import java.util.List;
 
 public class FixResource implements RestResource {
-  public static final TypeLiteral<RestView<FixResource>> FIX_KIND =
-      new TypeLiteral<RestView<FixResource>>() {};
+  public static final TypeLiteral<RestView<FixResource>> FIX_KIND = new TypeLiteral<>() {};
 
   private final List<FixReplacement> fixReplacements;
   private final RevisionResource revisionResource;
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index b1f9726..9a75469 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -65,6 +65,33 @@
    */
   public List<RelatedChangesSorter.PatchSetData> getRelated(ChangeData changeData, PatchSet basePs)
       throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, false);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sort(cds, basePs);
+  }
+
+  /**
+   * Gets ancestor changes of a specific change revision.
+   *
+   * @param changeData the change of the inputted revision.
+   * @param basePs the revision that the method checks for related changes.
+   * @param alwaysIncludeOriginalChange whether to return the given change when no ancestors found.
+   * @return list of ancestor changes, sorted via {@link RelatedChangesSorter}
+   */
+  public List<RelatedChangesSorter.PatchSetData> getAncestors(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange)
+      throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, alwaysIncludeOriginalChange);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sortAncestors(cds, basePs);
+  }
+
+  private List<ChangeData> getUnsortedRelated(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange) {
     Set<String> groups = getAllGroups(changeData.patchSets());
     logger.atFine().log("groups = %s", groups);
     if (groups.isEmpty()) {
@@ -72,18 +99,16 @@
     }
 
     List<ChangeData> cds =
-        InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, changeData.project(), groups);
+        InternalChangeQuery.byBranchGroups(
+            queryProvider, indexConfig, changeData.change().getDest(), groups);
     if (cds.isEmpty()) {
       return Collections.emptyList();
     }
     if (cds.size() == 1 && cds.get(0).getId().equals(changeData.getId())) {
-      return Collections.emptyList();
+      return alwaysIncludeOriginalChange ? cds : Collections.emptyList();
     }
 
-    cds = reloadChangeIfStale(cds, changeData, basePs);
-
-    return sorter.sort(cds, basePs);
+    return reloadChangeIfStale(cds, changeData, basePs);
   }
 
   private List<ChangeData> reloadChangeIfStale(
diff --git a/java/com/google/gerrit/server/change/HumanCommentResource.java b/java/com/google/gerrit/server/change/HumanCommentResource.java
index 1611aaa..93a0698 100644
--- a/java/com/google/gerrit/server/change/HumanCommentResource.java
+++ b/java/com/google/gerrit/server/change/HumanCommentResource.java
@@ -23,7 +23,7 @@
 
 public class HumanCommentResource implements RestResource {
   public static final TypeLiteral<RestView<HumanCommentResource>> COMMENT_KIND =
-      new TypeLiteral<RestView<HumanCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final HumanComment comment;
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index c06ce82..94498d7 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
@@ -37,10 +38,15 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 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.RevWalk;
@@ -75,13 +81,26 @@
         throw new ResourceConflictException(err.getMessage());
       }
 
-      IncludedInResolver.Result d = IncludedInResolver.resolve(r, rw, rev);
+      RefDatabase refDb = r.getRefDatabase();
+      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);
+
+      Set<String> allMatchingTagsAndBranches =
+          rw.getMergedInto(rev, IncludedInUtil.getSortedRefs(allTagsAndBranches, rw)).stream()
+              .map(Ref::getName)
+              .collect(Collectors.toSet());
 
       // Filter branches and tags according to their visbility by the user
       ImmutableSortedSet<String> filteredBranches =
-          sortedShortNames(filterReadableRefs(project, d.branches()));
+          sortedShortNames(
+              filterReadableRefs(
+                  project, getMatchingRefNames(allMatchingTagsAndBranches, branches)));
       ImmutableSortedSet<String> filteredTags =
-          sortedShortNames(filterReadableRefs(project, d.tags()));
+          sortedShortNames(
+              filterReadableRefs(project, getMatchingRefNames(allMatchingTagsAndBranches, tags)));
 
       ListMultimap<String, String> external = MultimapBuilder.hashKeys().arrayListValues().build();
       externalIncludedIn.runEach(
@@ -115,6 +134,18 @@
     }
   }
 
+  /**
+   * Returns the short names of refs which are as well in the matchingRefs list as well as in the
+   * allRef list.
+   */
+  private static ImmutableList<Ref> getMatchingRefNames(
+      Set<String> matchingRefs, Collection<Ref> allRefs) {
+    return allRefs.stream()
+        .filter(r -> matchingRefs.contains(r.getName()))
+        .distinct()
+        .collect(toImmutableList());
+  }
+
   private ImmutableSortedSet<String> sortedShortNames(Collection<String> refs) {
     return refs.stream()
         .map(Repository::shortenRefName)
diff --git a/java/com/google/gerrit/server/change/IncludedInRefs.java b/java/com/google/gerrit/server/change/IncludedInRefs.java
new file mode 100644
index 0000000..17a0c9d
--- /dev/null
+++ b/java/com/google/gerrit/server/change/IncludedInRefs.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.toSet;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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.inject.Inject;
+import java.io.IOException;
+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 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.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class IncludedInRefs {
+  protected final GitRepositoryManager repoManager;
+  protected final PermissionBackend permissionBackend;
+
+  @Inject
+  IncludedInRefs(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public Map<String, Set<String>> apply(
+      Project.NameKey project, Set<String> commits, Set<String> refNames)
+      throws IOException, PermissionBackendException {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Set<Ref> visibleRefs = getVisibleRefs(repo, refNames, project);
+
+      if (!visibleRefs.isEmpty()) {
+        try (RevWalk revWalk = new RevWalk(repo)) {
+          revWalk.setRetainBody(false);
+          Set<RevCommit> revCommits = getRevCommits(commits, revWalk);
+
+          if (!revCommits.isEmpty()) {
+            return commitsIncludedIn(
+                revCommits, IncludedInUtil.getSortedRefs(visibleRefs, revWalk), revWalk);
+          }
+        }
+      }
+    }
+    return ImmutableMap.of();
+  }
+
+  private Set<Ref> getVisibleRefs(Repository repo, Set<String> refNames, Project.NameKey project)
+      throws PermissionBackendException {
+    RefDatabase refDb = repo.getRefDatabase();
+    Set<Ref> refs = new HashSet<>();
+    for (String refName : refNames) {
+      try {
+        Ref ref = refDb.exactRef(refName);
+        if (ref != null) {
+          refs.add(ref);
+        }
+      } catch (IOException e) {
+        // Ignore and continue to process rest of the refs so as to keep
+        // the behavior similar to the ref not being visible to the user.
+        // This will ensure that there is no information leak about the
+        // ref when the ref is corrupted and is not visible to the user.
+      }
+    }
+    return filterReadableRefs(project, refs, repo);
+  }
+
+  private Set<RevCommit> getRevCommits(Set<String> commits, RevWalk revWalk) throws IOException {
+    Set<RevCommit> revCommits = new HashSet<>();
+    for (String commit : commits) {
+      try {
+        revCommits.add(revWalk.parseCommit(ObjectId.fromString(commit)));
+      } catch (MissingObjectException | IncorrectObjectTypeException | IllegalArgumentException e) {
+        // Ignore and continue to process the rest of the commits so as to keep
+        // the behavior similar to the commit not being included in any of the
+        // visible specified refs. This will ensure that there is no information
+        // leak about the commit when the commit is not visible to the user.
+      }
+    }
+    return revCommits;
+  }
+
+  private Map<String, Set<String>> commitsIncludedIn(
+      Collection<RevCommit> commits, Collection<Ref> refs, RevWalk revWalk) throws IOException {
+    Map<String, Set<String>> refsByCommit = new HashMap<>();
+    for (RevCommit commit : commits) {
+      List<Ref> matchingRefs = revWalk.getMergedInto(commit, refs);
+      if (matchingRefs.size() > 0) {
+        refsByCommit.put(
+            commit.getName(), matchingRefs.stream().map(Ref::getName).collect(toSet()));
+      }
+    }
+    return refsByCommit;
+  }
+
+  /**
+   * Filter readable refs according to the caller's refs visibility.
+   *
+   * @param project specific Gerrit project.
+   * @param inputRefs a list of refs
+   * @param repo repository opened for the Gerrit project.
+   * @return set of visible refs to the caller
+   */
+  private Set<Ref> filterReadableRefs(Project.NameKey project, Set<Ref> inputRefs, Repository repo)
+      throws PermissionBackendException {
+    PermissionBackend.ForProject perm = permissionBackend.currentUser().project(project);
+    return perm.filter(inputRefs, repo, RefFilterOptions.defaults()).stream().collect(toSet());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
deleted file mode 100644
index b2b0a64..0000000
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ /dev/null
@@ -1,161 +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.change;
-
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-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.RevFlag;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/** Resolve in which tags and branches a commit is included. */
-public class IncludedInResolver {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static Result resolve(Repository repo, RevWalk rw, RevCommit commit) throws IOException {
-    RevFlag flag = newFlag(rw);
-    try {
-      return new IncludedInResolver(repo, rw, commit, flag).resolve();
-    } finally {
-      rw.disposeFlag(flag);
-    }
-  }
-
-  private static RevFlag newFlag(RevWalk rw) {
-    return rw.newFlag("CONTAINS_TARGET");
-  }
-
-  private final Repository repo;
-  private final RevWalk rw;
-  private final RevCommit target;
-
-  private final RevFlag containsTarget;
-  private ListMultimap<RevCommit, String> commitToRef;
-  private List<RevCommit> tipsByCommitTime;
-
-  private IncludedInResolver(
-      Repository repo, RevWalk rw, RevCommit target, RevFlag containsTarget) {
-    this.repo = repo;
-    this.rw = rw;
-    this.target = target;
-    this.containsTarget = containsTarget;
-  }
-
-  private Result resolve() throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
-    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);
-
-    return new AutoValue_IncludedInResolver_Result(
-        getMatchingRefNames(allMatchingTagsAndBranches, branches),
-        getMatchingRefNames(allMatchingTagsAndBranches, tags));
-  }
-
-  /** Resolves which tip refs include the target commit. */
-  private Set<String> includedIn(Collection<RevCommit> tips, int limit)
-      throws IOException, MissingObjectException, IncorrectObjectTypeException {
-    Set<String> result = new HashSet<>();
-    for (RevCommit tip : tips) {
-      boolean commitFound = false;
-      rw.resetRetain(RevFlag.UNINTERESTING, containsTarget);
-      rw.markStart(tip);
-      for (RevCommit commit : rw) {
-        if (commit.equals(target) || commit.has(containsTarget)) {
-          commitFound = true;
-          tip.add(containsTarget);
-          result.addAll(commitToRef.get(tip));
-          break;
-        }
-      }
-      if (!commitFound) {
-        rw.markUninteresting(tip);
-      } else if (0 < limit && limit < result.size()) {
-        break;
-      }
-    }
-    return result;
-  }
-
-  /**
-   * Returns the short names of refs which are as well in the matchingRefs list as well as in the
-   * allRef list.
-   */
-  private static ImmutableList<Ref> getMatchingRefNames(
-      Set<String> matchingRefs, Collection<Ref> allRefs) {
-    return allRefs.stream()
-        .filter(r -> matchingRefs.contains(r.getName()))
-        .distinct()
-        .collect(ImmutableList.toImmutableList());
-  }
-
-  /** Parse commit of ref and store the relation between ref and commit. */
-  private void parseCommits(Collection<Ref> refs) throws IOException {
-    if (commitToRef != null) {
-      return;
-    }
-    commitToRef = LinkedListMultimap.create();
-    for (Ref ref : refs) {
-      final RevCommit commit;
-      try {
-        commit = rw.parseCommit(ref.getObjectId());
-      } catch (IncorrectObjectTypeException notCommit) {
-        // Its OK for a tag reference to point to a blob or a tree, this
-        // is common in the Linux kernel or git.git repository.
-        //
-        continue;
-      } catch (MissingObjectException notHere) {
-        // Log the problem with this branch, but keep processing.
-        //
-        logger.atWarning().log(
-            "Reference %s in %s points to dangling object %s",
-            ref.getName(), repo.getDirectory(), ref.getObjectId());
-        continue;
-      }
-      commitToRef.put(commit, ref.getName());
-    }
-    tipsByCommitTime =
-        commitToRef.keySet().stream().sorted(comparing(RevCommit::getCommitTime)).collect(toList());
-  }
-
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<Ref> branches();
-
-    public abstract ImmutableList<Ref> tags();
-  }
-}
diff --git a/java/com/google/gerrit/server/change/IncludedInUtil.java b/java/com/google/gerrit/server/change/IncludedInUtil.java
new file mode 100644
index 0000000..6f75e0f
--- /dev/null
+++ b/java/com/google/gerrit/server/change/IncludedInUtil.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class IncludedInUtil {
+
+  /**
+   * Sorts the collection of {@code Ref} instances by its tip commit time.
+   *
+   * @param refs collection to be sorted
+   * @param revWalk {@code RevWalk} instance for parsing ref's tip commit
+   * @return sorted list of refs
+   */
+  public static List<Ref> getSortedRefs(Collection<Ref> refs, RevWalk revWalk) {
+    return refs.stream()
+        .sorted(
+            comparing(
+                ref -> {
+                  try {
+                    return revWalk.parseCommit(ref.getObjectId()).getCommitTime();
+                  } catch (IOException e) {
+                    // Ignore and continue to sort
+                  }
+                  return 0;
+                }))
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index aeb9db0..b1fcf48 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.auto.value.AutoValue;
 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.common.collect.Lists;
+import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -32,8 +33,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.List;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -42,6 +44,16 @@
  * what labels are defined for the project. The label definition can change between the time a vote
  * is originally made and a later point, for example when a change is submitted. This class
  * normalizes old votes against current project configuration.
+ *
+ * <p>Normalizing a vote means making it compliant with the current label definition:
+ *
+ * <ul>
+ *   <li>If the voting value is greater than the max allowed value according to the label
+ *       definition, the voting value is changed to the max allowed value.
+ *   <li>If the voting value is lower than the min allowed value according to the label definition,
+ *       the voting value is changed to the min allowed value.
+ *   <li>If the label definition for a vote is missing, the vote is deleted.
+ * </ul>
  */
 @Singleton
 public class LabelNormalizer {
@@ -49,23 +61,25 @@
   public abstract static class Result {
     @VisibleForTesting
     static Result create(
-        List<PatchSetApproval> unchanged,
-        List<PatchSetApproval> updated,
-        List<PatchSetApproval> deleted) {
+        Set<PatchSetApproval> unchanged,
+        Set<PatchSetApproval> updated,
+        Set<PatchSetApproval> deleted) {
       return new AutoValue_LabelNormalizer_Result(
-          ImmutableList.copyOf(unchanged),
-          ImmutableList.copyOf(updated),
-          ImmutableList.copyOf(deleted));
+          ImmutableSet.copyOf(unchanged),
+          ImmutableSet.copyOf(updated),
+          ImmutableSet.copyOf(deleted));
     }
 
-    public abstract ImmutableList<PatchSetApproval> unchanged();
+    public abstract ImmutableSet<PatchSetApproval> unchanged();
 
-    public abstract ImmutableList<PatchSetApproval> updated();
+    public abstract ImmutableSet<PatchSetApproval> updated();
 
-    public abstract ImmutableList<PatchSetApproval> deleted();
+    public abstract ImmutableSet<PatchSetApproval> deleted();
 
-    public Iterable<PatchSetApproval> getNormalized() {
-      return Iterables.concat(unchanged(), updated());
+    public ImmutableSet<PatchSetApproval> getNormalized() {
+      return Streams.concat(unchanged().stream(), updated().stream())
+          .distinct()
+          .collect(toImmutableSet());
     }
   }
 
@@ -84,9 +98,9 @@
    * @param approvals list of approvals.
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals) {
-    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
+    Set<PatchSetApproval> unchanged = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> updated = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> deleted = new HashSet<>(approvals.size());
     LabelTypes labelTypes =
         projectCache
             .get(notes.getProjectName())
@@ -118,6 +132,20 @@
     return Result.create(unchanged, updated, deleted);
   }
 
+  /**
+   * Returns a copy of the given approval normalized to the defined ranges for the label type. If
+   * the approval is for an unknown label {@link Optional#empty()} is returned
+   *
+   * @param notes change notes containing the given approval
+   * @param approval approval that should be normalized
+   */
+  public Optional<PatchSetApproval> normalize(ChangeNotes notes, PatchSetApproval approval) {
+    Result result = normalize(notes, ImmutableSet.of(approval));
+    return Optional.ofNullable(
+        Iterables.getFirst(
+            result.unchanged(), Iterables.getFirst(result.updated(), /* defaultValue= */ null)));
+  }
+
   private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
     PatchSetApproval.Builder b = a.toBuilder();
     LabelValue atMin = lt.getMin();
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 5ce121b..5555ba6 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -36,19 +36,23 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.common.AccountInfo;
 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.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
 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.project.DeleteVoteControl;
+import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -69,10 +73,17 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
+  private final DeleteVoteControl deleteVoteControl;
+  private final RemoveReviewerControl removeReviewerControl;
 
   @Inject
-  LabelsJson(PermissionBackend permissionBackend) {
+  LabelsJson(
+      PermissionBackend permissionBackend,
+      DeleteVoteControl deleteVoteControl,
+      RemoveReviewerControl removeReviewerControl) {
     this.permissionBackend = permissionBackend;
+    this.deleteVoteControl = deleteVoteControl;
+    this.removeReviewerControl = removeReviewerControl;
   }
 
   /**
@@ -80,6 +91,7 @@
    * lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
    * populate all accounts in the returned {@link LabelInfo}s.
    */
+  @Nullable
   Map<String, LabelInfo> labelsFor(
       AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
       throws PermissionBackendException {
@@ -95,53 +107,84 @@
     return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
-  /** Returns all labels that the provided user has permission to vote on. */
+  /**
+   * Returns A map of all label names and the values that the provided user has permission to vote
+   * on.
+   *
+   * @param filterApprovalsBy a Gerrit user ID.
+   * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+   * @return A Map where the key contain a label name, and the value is a list of the permissible
+   *     vote values that the user can 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) {
-          Optional<LabelType> type = labelTypes.byLabel(r.label);
-          if (type.isPresent() && (!isMerged || type.get().isAllowPostSubmit())) {
-            toCheck.put(type.get().getName(), type.get());
-          }
-        }
-      }
-    }
-
-    Map<String, Short> labels = null;
-    Set<LabelPermission.WithValue> can =
-        permissionBackend.absentUser(filterApprovalsBy).change(cd).testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
+    boolean isMerged = cd.change().isMerged();
+    Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
+    for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
+      if (isMerged && !labelType.isAllowPostSubmit()) {
         continue;
       }
-      for (SubmitRecord.Label r : rec.labels) {
-        Optional<LabelType> type = labelTypes.byLabel(r.label);
-        if (!type.isPresent() || (isMerged && !type.get().isAllowPostSubmit())) {
-          continue;
+      Set<LabelPermission.WithValue> can =
+          permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
+      for (LabelValue v : labelType.getValues()) {
+        boolean ok = can.contains(new LabelPermission.WithValue(labelType, v));
+        if (isMerged) {
+          // Votes cannot be decreased if the change is merged. Only accept the label value if it's
+          // greater or equal than the user's latest vote.
+          short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0);
+          ok &= v.getValue() >= prev;
         }
-
-        for (LabelValue v : type.get().getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type.get(), v));
-          if (isMerged) {
-            if (labels == null) {
-              labels = currentLabels(filterApprovalsBy, cd);
-            }
-            short prev = labels.getOrDefault(type.get().getName(), (short) 0);
-            ok &= v.getValue() >= prev;
-          }
-          if (ok) {
-            permitted.put(r.label, v.formatValue());
-          }
+        if (ok) {
+          permitted.put(labelType.getName(), v.formatValue());
         }
       }
     }
+    clearOnlyZerosEntries(permitted);
+    return permitted.asMap();
+  }
 
+  /**
+   * Returns A map of all labels that the provided user has permission to remove.
+   *
+   * @param accountLoader to load the reviewers' data with.
+   * @param user a Gerrit user.
+   * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+   * @return A Map of {@code labelName} -> {Map of {@code value} -> List of {@link AccountInfo}}
+   *     that the user can remove votes from.
+   */
+  Map<String, Map<String, List<AccountInfo>>> removableLabels(
+      AccountLoader accountLoader, CurrentUser user, ChangeData cd)
+      throws PermissionBackendException {
+    if (cd.change().isMerged()) {
+      return new HashMap<>();
+    }
+
+    Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
+    LabelTypes labelTypes = cd.getLabelTypes();
+    for (PatchSetApproval approval : cd.currentApprovals()) {
+      Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
+      if (!labelType.isPresent()) {
+        continue;
+      }
+      if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
+          || removeReviewerControl.testRemoveReviewer(
+              cd, user, approval.accountId(), approval.value()))) {
+        continue;
+      }
+      if (!res.containsKey(approval.label())) {
+        res.put(approval.label(), new HashMap<>());
+      }
+      String labelValue = LabelValue.formatValue(approval.value());
+      if (!res.get(approval.label()).containsKey(labelValue)) {
+        res.get(approval.label()).put(labelValue, new ArrayList<>());
+      }
+      res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
+    }
+    return res;
+  }
+
+  private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
       if (isOnlyZero(e.getValue())) {
@@ -151,7 +194,6 @@
     for (String label : toClear) {
       permitted.removeAll(label);
     }
-    return permitted.asMap();
   }
 
   private static boolean isOnlyZero(Collection<String> values) {
@@ -173,9 +215,8 @@
       boolean detailed)
       throws PermissionBackendException {
     Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard);
-    if (detailed) {
-      setAllApprovals(accountLoader, cd, labels);
-    }
+    setAllApprovals(accountLoader, cd, labels, detailed);
+
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
       Optional<LabelType> type = labelTypes.byLabel(e.getKey());
       if (!type.isPresent()) {
@@ -190,9 +231,7 @@
           }
         }
       }
-      if (detailed) {
-        setLabelValues(type.get(), e.getValue());
-      }
+      setLabelValues(type.get(), e.getValue());
     }
     return labels;
   }
@@ -209,10 +248,10 @@
   private ApprovalInfo approvalInfo(
       AccountLoader accountLoader,
       Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
+      @Nullable Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      @Nullable Instant date) {
     ApprovalInfo ai = new ApprovalInfo(id.get(), value, permittedVotingRange, tag, date);
     accountLoader.put(ai);
     return ai;
@@ -229,10 +268,10 @@
     }
   }
 
-  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
+  private Map<String, Short> currentLabels(@Nullable Account.Id accountId, ChangeData cd) {
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa : cd.currentApprovals()) {
-      if (psa.accountId().equals(accountId)) {
+      if (accountId == null || psa.accountId().equals(accountId)) {
         result.put(psa.label(), psa.value());
       }
     }
@@ -291,22 +330,20 @@
       }
     }
 
-    if (detailed) {
-      labels.entrySet().stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
-    }
+    labels.entrySet().stream()
+        .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
+        .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), 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 (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)) {
         Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
@@ -319,7 +356,7 @@
         if (info != null) {
           info.value = Integer.valueOf(val);
           info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
-          info.date = psa.granted();
+          info.setDate(psa.granted());
           info.tag = psa.tag().orElse(null);
           if (psa.postSubmit()) {
             info.postSubmit = true;
@@ -368,9 +405,23 @@
         }
       }
     }
+    setLabelsDescription(labels, labelTypes);
     return labels;
   }
 
+  private void setLabelsDescription(
+      Map<String, LabelsJson.LabelWithStatus> labels, LabelTypes labelTypes) {
+    for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+      String labelName = entry.getKey();
+      Optional<LabelType> type = labelTypes.byLabel(labelName);
+      if (!type.isPresent()) {
+        continue;
+      }
+      LabelWithStatus labelWithStatus = entry.getValue();
+      labelWithStatus.label().description = type.get().getDescription().orElse(null);
+    }
+  }
+
   private void setLabelScores(
       AccountLoader accountLoader,
       LabelType type,
@@ -402,7 +453,10 @@
   }
 
   private void setAllApprovals(
-      AccountLoader accountLoader, ChangeData cd, Map<String, LabelWithStatus> labels)
+      AccountLoader accountLoader,
+      ChangeData cd,
+      Map<String, LabelWithStatus> labels,
+      boolean detailed)
       throws PermissionBackendException {
     checkState(
         !cd.change().isMerged(),
@@ -426,8 +480,12 @@
 
     LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      Map<String, VotingRangeInfo> pvr = null;
+      PermissionBackend.ForChange perm = null;
+      if (detailed) {
+        perm = permissionBackend.absentUser(accountId).change(cd);
+        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      }
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
         if (!lt.isPresent()) {
@@ -436,9 +494,10 @@
           continue;
         }
         Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.get().getName(), null);
+        VotingRangeInfo permittedVotingRange =
+            pvr == null ? null : pvr.getOrDefault(lt.get().getName(), null);
         String tag = null;
-        Timestamp date = null;
+        Instant date = null;
         PatchSetApproval psa = current.get(accountId, lt.get().getName());
         if (psa != null) {
           value = Integer.valueOf(psa.value());
@@ -446,7 +505,7 @@
             // 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.get())) ? 0 : null;
+            value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
           }
           tag = psa.tag().orElse(null);
           date = psa.granted();
@@ -457,7 +516,7 @@
           // 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.get())) ? 0 : null;
+          value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
         }
         addApproval(
             e.getValue().label(),
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 209901d..cff3de2 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -15,17 +15,20 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.flogger.FluentLogger;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,14 +36,13 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
@@ -65,8 +67,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class PatchSetInserter implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
   }
@@ -74,15 +74,15 @@
   // Injected fields.
   private final PermissionBackend permissionBackend;
   private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ChangeKindCache changeKindCache;
   private final CommitValidators.Factory commitValidatorsFactory;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
-  private final MessageIdGenerator messageIdGenerator;
   private final AutoMerger autoMerger;
 
   // Assisted-injected fields.
@@ -100,6 +100,7 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private List<String> groups = Collections.emptyList();
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private boolean fireRevisionCreated = true;
   private boolean allowClosed;
   private boolean sendEmail = true;
@@ -110,9 +111,12 @@
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
+  private ChangeKind changeKind;
   private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
+  private ApprovalCopier.Result approvalCopierResult;
+  private ObjectId preUpdateMetaId;
 
   @Inject
   public PatchSetInserter(
@@ -120,13 +124,13 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
+      ChangeKindCache changeKindCache,
       CommitValidators.Factory commitValidatorsFactory,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
-      MessageIdGenerator messageIdGenerator,
       AutoMerger autoMerger,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
@@ -135,13 +139,13 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.patchSetInfoFactory = patchSetInfoFactory;
+    this.changeKindCache = changeKindCache;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
-    this.messageIdGenerator = messageIdGenerator;
     this.autoMerger = autoMerger;
 
     this.origNotes = notes;
@@ -184,6 +188,13 @@
     return this;
   }
 
+  public PatchSetInserter setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
@@ -230,6 +241,15 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+
+    changeKind =
+        changeKindCache.getChangeKind(
+            ctx.getProject(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            psUtil.current(origNotes).commitId(),
+            commitId);
+
     Optional<ReceiveCommand> autoMerge =
         autoMerger.createAutoMergeCommitIfNecessary(
             ctx.getRepoView(),
@@ -244,6 +264,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setSubjectForCommit("Create patch set " + psId.get());
@@ -270,12 +291,6 @@
       oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
     }
 
-    if (message != null) {
-      mailMessage =
-          cmUtil.setChangeMessage(
-              update, message, ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-    }
-
     oldWorkInProgressState = change.isWorkInProgress();
     if (workInProgress != null) {
       change.setWorkInProgress(workInProgress);
@@ -298,36 +313,69 @@
       }
     }
 
-    // Approvals that are being set in the new patch-set during this operation are not available yet
-    // outside of the scope of this method. Only copied approvals are set here.
     if (storeCopiedVotes) {
-      approvalsUtil.byPatchSet(ctx.getNotes(), patchSet).forEach(a -> update.putCopiedApproval(a));
+      approvalCopierResult =
+          approvalsUtil.copyApprovalsToNewPatchSet(
+              ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
     }
 
+    mailMessage = insertChangeMessage(update, ctx);
+
     return true;
   }
 
+  @Nullable
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx) {
+    StringBuilder messageBuilder = new StringBuilder();
+    if (message != null) {
+      messageBuilder.append(message);
+    }
+
+    if (approvalCopierResult != null) {
+      approvalsUtil
+          .formatApprovalCopierResult(
+              approvalCopierResult,
+              projectCache
+                  .get(ctx.getProject())
+                  .orElseThrow(illegalState(ctx.getProject()))
+                  .getLabelTypes())
+          .ifPresent(
+              msg -> {
+                if (message != null && !message.endsWith("\n")) {
+                  messageBuilder.append("\n");
+                }
+                messageBuilder.append("\n").append(msg);
+              });
+    }
+
+    String changeMessage = messageBuilder.toString();
+    if (changeMessage.isEmpty()) {
+      return null;
+    }
+
+    return cmUtil.setChangeMessage(
+        update,
+        messageBuilder.toString(),
+        ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
+  }
+
   @Override
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (notify.shouldNotify() && sendEmail) {
-      requireNonNull(mailMessage);
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.addReviewers(oldReviewers.byState(REVIEWER));
-        emailSender.addExtraCC(oldReviewers.byState(CC));
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-        emailSender.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for new patch set on change %s", change.getId());
-      }
+    if (sendEmail) {
+      emailNewPatchSetFactory
+          .create(
+              ctx,
+              patchSet,
+              mailMessage,
+              approvalCopierResult.outdatedApprovals().stream()
+                  .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                  .collect(toImmutableSet()),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(REVIEWER),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(CC),
+              changeKind,
+              preUpdateMetaId)
+          .sendAsync();
     }
 
     if (fireRevisionCreated) {
@@ -368,7 +416,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
-            ImmutableListMultimap.of(),
+            validationOptions,
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 3e67cca..49ec812 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -17,10 +17,14 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -28,11 +32,14 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -43,8 +50,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -70,19 +77,24 @@
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
+
+    RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, Change.Id baseChangeId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeNotes.Factory notesFactory;
 
   private final ChangeNotes notes;
   private final PatchSet originalPatchSet;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectCache projectCache;
+  private final Project.NameKey projectName;
 
   private ObjectId baseCommitId;
+  private Change.Id baseChangeId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
   private boolean validate = true;
@@ -94,32 +106,85 @@
   private boolean sendEmail = true;
   private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @Inject
+  @AssistedInject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
       @Assisted ObjectId baseCommitId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseCommitId = baseCommitId;
+    this.baseChangeId = null;
+  }
+
+  @AssistedInject
+  RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted PatchSet originalPatchSet,
+      @Assisted Change.Id baseChangeId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseChangeId = baseChangeId;
+    this.baseCommitId = null;
+  }
+
+  private RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      ChangeNotes notes,
+      PatchSet originalPatchSet) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
+    this.notesFactory = notesFactory;
     this.identifiedUserFactory = identifiedUserFactory;
     this.projectCache = projectCache;
     this.notes = notes;
+    this.projectName = notes.getProjectName();
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -191,16 +256,32 @@
     return this;
   }
 
+  public RebaseChangeOp setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
-      throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          NoSuchChangeException, PermissionBackendException {
+      throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
+          PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
-    RevCommit baseCommit = rw.parseCommit(baseCommitId);
+    RevCommit baseCommit;
+    if (baseCommitId != null && baseChangeId == null) {
+      baseCommit = rw.parseCommit(baseCommitId);
+    } else if (baseChangeId != null) {
+      baseCommit =
+          PatchSetUtil.getCurrentRevCommitIncludingPending(ctx, notesFactory, baseChangeId);
+    } else {
+      throw new IllegalStateException(
+          "Exactly one of base commit and base change must be provided.");
+    }
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
 
     String newCommitMessage;
@@ -213,12 +294,12 @@
       newCommitMessage = original.getFullMessage();
     }
 
-    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage, notes.getChangeId());
     Base base =
         rebaseUtil.parseBase(
             new RevisionResource(
                 changeResourceFactory.create(notes, changeOwner), originalPatchSet),
-            baseCommitId.name());
+            baseCommit.getName());
 
     rebasedPatchSetId =
         ChangeUtil.nextPatchSetIdFromChangeRefs(
@@ -241,6 +322,8 @@
       patchSetInserter.setWorkInProgress(true);
     }
 
+    patchSetInserter.setValidationOptions(validationOptions);
+
     if (postMessage) {
       patchSetInserter.setMessage(
           messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
@@ -307,8 +390,7 @@
   }
 
   private MergeUtil newMergeUtil() {
-    ProjectState project =
-        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    ProjectState project = projectCache.get(projectName).orElseThrow(illegalState(projectName));
     return forceContentMerge
         ? mergeUtilFactory.create(project, true)
         : mergeUtilFactory.create(project);
@@ -325,7 +407,11 @@
    * @throws IOException the merge failed for another reason.
    */
   private CodeReviewCommit rebaseCommit(
-      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      RepoContext ctx,
+      RevCommit original,
+      ObjectId base,
+      String commitMessage,
+      Change.Id originalChangeId)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
@@ -359,8 +445,9 @@
 
       if (!allowConflicts || !(merger instanceof ResolveMerger)) {
         throw new MergeConflictException(
-            "The change could not be rebased due to a conflict during merge.\n\n"
-                + MergeUtil.createConflictMessage(conflicts));
+            String.format(
+                "Change %s could not be rebased due to a conflict during merge.\n\n%s",
+                originalChangeId.toString(), MergeUtil.createConflictMessage(conflicts)));
       }
 
       Map<String, MergeResult<? extends Sequence>> mergeResults =
@@ -392,7 +479,7 @@
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
-      cb.setCommitter(ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone()));
+      cb.setCommitter(ctx.newCommitterIdent());
     }
     if (matchAuthorToCommitterDate) {
       cb.setAuthor(
@@ -400,7 +487,6 @@
               cb.getAuthor(), cb.getCommitter().getWhen(), cb.getCommitter().getTimeZone()));
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
-    ctx.getInserter().flush();
     CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
     commit.setFilesWithGitConflicts(filesWithGitConflicts);
     return commit;
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 2d36df2..8acc925 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -17,16 +17,24 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+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.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 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.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -45,20 +53,65 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
+  private final RebaseChangeOp.Factory rebaseFactory;
 
   @Inject
   RebaseUtil(
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      RebaseChangeOp.Factory rebaseFactory) {
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
+    this.rebaseFactory = rebaseFactory;
+  }
+
+  /**
+   * Checks whether the given change fulfills all preconditions to be rebased.
+   *
+   * <p>This method does not check whether the calling user is allowed to rebase the change.
+   */
+  public void verifyRebasePreconditions(RevWalk rw, ChangeNotes changeNotes, PatchSet patchSet)
+      throws ResourceConflictException, IOException {
+    // Not allowed to rebase if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(changeNotes);
+
+    Change change = changeNotes.getChange();
+    if (!change.isNew()) {
+      throw new ResourceConflictException(
+          String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
+    }
+
+    if (!hasOneParent(rw, patchSet)) {
+      throw new ResourceConflictException(
+          String.format(
+              "Error rebasing %s. Cannot rebase %s",
+              change.getId(),
+              countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
+    }
+  }
+
+  public static boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
+    // Prevent rebase of exotic changes (merge commit, no ancestor).
+    return countParents(rw, ps) == 1;
+  }
+
+  private static int countParents(RevWalk rw, PatchSet ps) throws IOException {
+    RevCommit c = rw.parseCommit(ps.commitId());
+    return c.getParentCount();
+  }
+
+  private static boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
+    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
-      findBaseRevision(patchSet, dest, git, rw);
+      @SuppressWarnings("unused")
+      ObjectId base = findBaseRevision(patchSet, dest, git, rw, true);
       return true;
     } catch (RestApiException e) {
       return false;
@@ -71,6 +124,7 @@
 
   @AutoValue
   public abstract static class Base {
+    @Nullable
     private static Base create(ChangeNotes notes, PatchSet ps) {
       if (notes == null) {
         return null;
@@ -127,6 +181,100 @@
   }
 
   /**
+   * Parse or find the commit onto which a patch set should be rebased.
+   *
+   * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, finds the latest patch set
+   * of the change corresponding to this commit's parent, or the destination branch tip in the case
+   * where the parent's change is merged.
+   *
+   * @param git the repository.
+   * @param rw the RevWalk.
+   * @param permissionBackend to check base reading permissions with.
+   * @param rsrc to find the base for
+   * @param rebaseInput to optionally parse the base from.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
+   * @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 PermissionBackendException if the user don't have permissions to read the base change.
+   */
+  public ObjectId parseOrFindBaseRevision(
+      Repository git,
+      RevWalk rw,
+      PermissionBackend permissionBackend,
+      RevisionResource rsrc,
+      RebaseInput rebaseInput,
+      boolean verifyNeedsRebase)
+      throws RestApiException, IOException, PermissionBackendException {
+    Change change = rsrc.getChange();
+
+    if (rebaseInput == null || rebaseInput.base == null) {
+      return findBaseRevision(rsrc.getPatchSet(), change.getDest(), git, rw, verifyNeedsRebase);
+    }
+
+    String inputBase = rebaseInput.base.trim();
+
+    if (inputBase.isEmpty()) {
+      return getDestRefTip(git, change.getDest());
+    }
+
+    Base base;
+    try {
+      base = parseBase(rsrc, inputBase);
+    } catch (NoSuchChangeException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base change not found: %s", inputBase), e);
+    }
+    if (base == null) {
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + inputBase);
+    }
+    return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
+  }
+
+  private ObjectId getDestRefTip(Repository git, BranchNameKey destRefKey)
+      throws ResourceConflictException, IOException {
+    // Remove existing dependency to other patch set.
+    Ref destRef = git.exactRef(destRefKey.branch());
+    if (destRef == null) {
+      throw new ResourceConflictException(
+          "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
+    }
+    return destRef.getObjectId();
+  }
+
+  private ObjectId getLatestRevisionForBaseChange(
+      RevWalk rw, PermissionBackend permissionBackend, RevisionResource childRsrc, Base base)
+      throws ResourceConflictException, AuthException, PermissionBackendException, IOException {
+
+    Change child = childRsrc.getChange();
+    PatchSet.Id baseId = base.patchSet().id();
+    if (child.getId().equals(baseId.changeId())) {
+      throw new ResourceConflictException(
+          String.format("cannot rebase change %s onto itself", childRsrc.getChange().getId()));
+    }
+
+    permissionBackend.user(childRsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
+
+    Change baseChange = base.notes().getChange();
+    if (!baseChange.getProject().equals(child.getProject())) {
+      throw new ResourceConflictException(
+          "base change is in wrong project: " + baseChange.getProject());
+    } else if (!baseChange.getDest().equals(child.getDest())) {
+      throw new ResourceConflictException(
+          "base change is targeting wrong branch: " + baseChange.getDest());
+    } else if (baseChange.isAbandoned()) {
+      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
+    } else if (isMergedInto(rw, childRsrc.getPatchSet(), base.patchSet())) {
+      throw new ResourceConflictException(
+          "base change "
+              + baseChange.getKey()
+              + " is a descendant of the current change - recursion not allowed");
+    }
+    return base.patchSet().commitId();
+  }
+
+  /**
    * Find the commit onto which a patch set should be rebased.
    *
    * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
@@ -136,12 +284,17 @@
    * @param destBranch the destination branch.
    * @param git the repository.
    * @param rw the RevWalk.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
    * @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.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
+      PatchSet patchSet,
+      BranchNameKey destBranch,
+      Repository git,
+      RevWalk rw,
+      boolean verifyNeedsRebase)
       throws RestApiException, IOException {
     ObjectId baseId = null;
     RevCommit commit = rw.parseCommit(patchSet.commitId());
@@ -168,7 +321,7 @@
         }
 
         if (depChange.isNew()) {
-          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
+          if (verifyNeedsRebase && depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
@@ -187,10 +340,29 @@
             "The destination branch does not exist: " + destBranch.branch());
       }
       baseId = destRef.getObjectId();
-      if (baseId.equals(parentId)) {
+      if (verifyNeedsRebase && baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
     return baseId;
   }
+
+  public RebaseChangeOp getRebaseOp(RevisionResource revRsrc, RebaseInput input, ObjectId baseRev) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev), input);
+  }
+
+  public RebaseChangeOp getRebaseOp(
+      RevisionResource revRsrc, RebaseInput input, Change.Id baseChange) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange), input);
+  }
+
+  private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
+    return op.setForceContentMerge(true)
+        .setAllowConflicts(input.allowConflicts)
+        .setValidationOptions(
+            ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
+        .setFireRevisionCreated(true);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 547452e..f4b1a83c 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -28,8 +28,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -77,16 +75,7 @@
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
     Map<ObjectId, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.commitId());
-    requireNonNull(
-        start,
-        () ->
-            String.format(
-                "commit %s of patch set %s not found in %s",
-                startPs.commitId().name(),
-                startPs.id(),
-                byId.entrySet().stream()
-                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
@@ -122,6 +111,34 @@
     return result;
   }
 
+  public List<PatchSetData> sortAncestors(List<ChangeData> in, PatchSet startPs)
+      throws IOException, PermissionBackendException {
+    checkArgument(!in.isEmpty(), "Input may not be empty");
+    // Map of all patch sets, keyed by commit SHA-1.
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
+
+    // Map of patch set -> immediate parent.
+    ListMultimap<PatchSetData, PatchSetData> parents =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+
+    for (ChangeData cd : in) {
+      for (PatchSet ps : cd.patchSets()) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+
+        for (RevCommit p : thisPsd.commit().getParents()) {
+          PatchSetData parentPsd = byId.get(p);
+          if (parentPsd != null) {
+            parents.put(thisPsd, parentPsd);
+          }
+        }
+      }
+    }
+
+    Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+    return List.copyOf(ancestors);
+  }
+
   private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
     Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
@@ -145,6 +162,19 @@
     return result;
   }
 
+  private PatchSetData getCheckedPatchSetData(Map<ObjectId, PatchSetData> byId, PatchSet ps) {
+    PatchSetData psData = byId.get(ps.commitId());
+    return requireNonNull(
+        psData,
+        () ->
+            String.format(
+                "commit %s of patch set %s not found in %s",
+                ps.commitId().name(),
+                ps.id(),
+                byId.entrySet().stream()
+                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+  }
+
   private Collection<PatchSetData> walkAncestors(
       ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
       throws PermissionBackendException {
@@ -196,7 +226,7 @@
       List<PatchSetData> start)
       throws PermissionBackendException {
     if (start.isEmpty()) {
-      return ImmutableList.of();
+      return new ArrayList<>();
     }
     Map<Change.Id, PatchSet.Id> maxPatchSetIds = new HashMap<>();
     Set<PatchSetData> seen = new HashSet<>();
@@ -238,12 +268,8 @@
 
   private boolean isVisible(PatchSetData psd) throws PermissionBackendException {
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
-    try {
-      perm.change(psd.data()).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      return false;
-    }
-    return projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
+    return perm.change(psd.data()).test(ChangePermission.READ)
+        && projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 50ee9d4..1d92521 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -32,19 +30,15 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -64,14 +58,12 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      MessageIdGenerator messageIdGenerator,
       RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.messageIdGenerator = messageIdGenerator;
     this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -94,8 +86,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
     return true;
   }
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 6189708..f0f3a8f 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.permissions.LabelPermission;
@@ -129,11 +128,8 @@
             continue;
           }
 
-          try {
-            perm.check(new LabelPermission(type.get()));
+          if (perm.test(new LabelPermission(type.get()))) {
             out.approvals.put(name, formatValue((short) 0));
-          } catch (AuthException e) {
-            // Do nothing.
           }
         }
       }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index fffb107..b5e0181 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.extensions.api.changes.ReviewerResult;
 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.server.AnonymousUser;
@@ -84,6 +83,7 @@
 import java.util.Optional;
 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.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -94,9 +94,21 @@
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
+  /**
+   * Controls which failures should be ignored.
+   *
+   * <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
+   * ignored a failure means that the operation fails.
+   */
   public enum FailureBehavior {
+    // All failures cause the operation to fail.
     FAIL,
-    IGNORE;
+
+    // Only not found failures cause the operation to fail, all other failures are ignored.
+    IGNORE_EXCEPT_NOT_FOUND,
+
+    // All failures are ignored.
+    IGNORE_ALL;
   }
 
   private enum FailureType {
@@ -113,6 +125,9 @@
      * resolving to an account/group/email.
      */
     public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
+
+    /** Whether the visibility check for the reviewer account should be skipped. */
+    public boolean skipVisibilityCheck = false;
   }
 
   public static InternalReviewerInput newReviewerInput(
@@ -143,7 +158,7 @@
     in.reviewer = accountId.toString();
     in.state = CC;
     in.notify = notify;
-    in.otherFailureBehavior = FailureBehavior.IGNORE;
+    in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
     return Optional.of(in);
   }
 
@@ -262,7 +277,14 @@
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      if (ReviewerState.REMOVED.equals(input.state)
+          || (input instanceof InternalReviewerInput
+              && ((InternalReviewerInput) input).skipVisibilityCheck)) {
+        reviewerUser =
+            accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
+      } else {
+        reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      }
       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
@@ -378,9 +400,10 @@
   @Nullable
   private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
       throws PermissionBackendException {
-    try {
-      permissionBackend.user(anonymousProvider.get()).change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
+    if (!permissionBackend
+        .user(anonymousProvider.get())
+        .change(notes)
+        .test(ChangePermission.READ)) {
       return fail(
           input,
           FailureType.OTHER,
@@ -399,15 +422,10 @@
 
   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;
-    }
+    // 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.
+    return permissionBackend.absentUser(member.id()).ref(branch).test(RefPermission.READ);
   }
 
   private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
@@ -458,9 +476,13 @@
       this.input = input;
       this.failureType = null;
       result = new ReviewerResult(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);
+      if (!state().equals(REMOVED)) {
+        // 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 = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true);
+      } else {
+        this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false);
+      }
       this.reviewersByEmail =
           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
       this.caller = caller.asIdentifiedUser();
@@ -494,12 +516,18 @@
       this.exactMatchFound = exactMatchFound;
     }
 
-    private ImmutableSet<Account> omitOwner(ChangeNotes notes, Iterable<Account> reviewers) {
-      return reviewers != null
-          ? Streams.stream(reviewers)
-              .filter(account -> !account.id().equals(notes.getChange().getOwner()))
-              .collect(toImmutableSet())
-          : ImmutableSet.of();
+    private ImmutableSet<Account> reviewersAsList(
+        ChangeNotes notes, @Nullable Iterable<Account> reviewers, boolean omitChangeOwner) {
+      if (reviewers == null) {
+        return ImmutableSet.of();
+      }
+
+      Stream<Account> reviewerStream = Streams.stream(reviewers);
+      if (omitChangeOwner) {
+        reviewerStream =
+            reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner()));
+      }
+      return reviewerStream.collect(toImmutableSet());
     }
 
     public void gatherResults(ChangeData cd) throws PermissionBackendException {
@@ -571,7 +599,9 @@
           (input instanceof InternalReviewerInput)
               ? ((InternalReviewerInput) input).otherFailureBehavior
               : FailureBehavior.FAIL;
-      return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
+      return behavior == FailureBehavior.IGNORE_ALL
+          || (failureType == FailureType.OTHER
+              && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
     }
   }
 
@@ -648,7 +678,7 @@
     }
 
     public <T> ImmutableSet<T> flattenResults(
-        Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
+        Function<ReviewerOp.Result, ? extends Collection<T>> func) {
       modifications()
           .forEach(
               a ->
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index 7a98f2b..e688a7b 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -29,7 +29,7 @@
 
 public class ReviewerResource implements RestResource {
   public static final TypeLiteral<RestView<ReviewerResource>> REVIEWER_KIND =
-      new TypeLiteral<RestView<ReviewerResource>>() {};
+      new TypeLiteral<>() {};
 
   public interface Factory {
     ReviewerResource create(ChangeResource change, Account.Id id);
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 33f3d4f..5469b51 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -48,7 +48,6 @@
 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.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
@@ -58,7 +57,7 @@
 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.git.MergeUtilFactory;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -88,7 +87,7 @@
     RevisionJson create(Iterable<ListChangesOption> options);
   }
 
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final FileInfoJson fileInfoJson;
   private final GpgApiAdapter gpgApi;
@@ -112,7 +111,7 @@
       AnonymousUser anonymous,
       ProjectCache projectCache,
       IdentifiedUser.GenericFactory userFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       FileInfoJson fileInfoJson,
       AccountLoader.Factory accountLoaderFactory,
       DynamicMap<DownloadScheme> downloadSchemes,
@@ -169,7 +168,8 @@
       RevCommit commit,
       boolean addLinks,
       boolean fillCommit,
-      String branchName)
+      String branchName,
+      String changeKey)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -183,7 +183,8 @@
 
     if (addLinks) {
       ImmutableList<WebLinkInfo> patchSetLinks =
-          webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
+          webLinks.getPatchSetLinks(
+              project, commit.name(), commit.getFullMessage(), branchName, changeKey);
       info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
       ImmutableList<WebLinkInfo> resolveConflictsLinks =
           webLinks.getResolveConflictsLinks(
@@ -285,7 +286,7 @@
     out.isCurrent = in.id().equals(c.currentPatchSetId());
     out._number = in.id().get();
     out.ref = in.refName();
-    out.created = in.createdOn();
+    out.setCreated(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);
@@ -302,7 +303,9 @@
       rw.parseBody(commit);
       String branchName = cd.change().getDest().branch();
       if (setCommit) {
-        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit, branchName);
+        out.commit =
+            getCommitInfo(
+                project, rw, commit, has(WEB_LINKS), fillCommit, branchName, c.getKey().get());
       }
       if (addFooters) {
         Ref ref = repo.exactRef(branchName);
@@ -353,9 +356,7 @@
   }
 
   private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException {
-    try {
-      permissionBackend.user(anonymous).change(cd).check(ChangePermission.READ);
-    } catch (AuthException ae) {
+    if (!permissionBackend.user(anonymous).change(cd).test(ChangePermission.READ)) {
       return false;
     }
     ProjectState projectState =
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index 30fa593..e5a57b2 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -35,7 +35,7 @@
 
 public class RevisionResource implements RestResource, HasETag {
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
-      new TypeLiteral<RestView<RevisionResource>>() {};
+      new TypeLiteral<>() {};
 
   public static RevisionResource createNonCacheable(ChangeResource change, PatchSet ps) {
     return new RevisionResource(change, ps, Optional.empty(), false);
diff --git a/java/com/google/gerrit/server/change/RobotCommentResource.java b/java/com/google/gerrit/server/change/RobotCommentResource.java
index b12727d..3662575 100644
--- a/java/com/google/gerrit/server/change/RobotCommentResource.java
+++ b/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -23,7 +23,7 @@
 
 public class RobotCommentResource implements RestResource {
   public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
-      new TypeLiteral<RestView<RobotCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final RobotComment comment;
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index 4833197..fcd9e90 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -40,18 +40,20 @@
               result.applicabilityExpressionResult().get(),
               /* hide= */ true); // Always hide applicability expressions on the API
     }
-    if (req.overrideExpression().isPresent()) {
+    if (req.overrideExpression().isPresent() && result.overrideExpressionResult().isPresent()) {
       info.overrideExpressionResult =
           submitRequirementExpressionToInfo(
               req.overrideExpression().get(),
               result.overrideExpressionResult().get(),
               /* hide= */ false);
     }
-    info.submittabilityExpressionResult =
-        submitRequirementExpressionToInfo(
-            req.submittabilityExpression(),
-            result.submittabilityExpressionResult(),
-            /* hide= */ false);
+    if (result.submittabilityExpressionResult().isPresent()) {
+      info.submittabilityExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.submittabilityExpression(),
+              result.submittabilityExpressionResult().get(),
+              /* hide= */ false);
+    }
     info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
     info.isLegacy = result.isLegacy();
     return info;
@@ -63,9 +65,13 @@
       boolean hide) {
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
     info.expression = hide ? null : expression.expressionString();
-    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.fulfilled =
+        result.status().equals(SubmitRequirementExpressionResult.Status.PASS)
+            || result.status().equals(SubmitRequirementExpressionResult.Status.NOT_EVALUATED);
+    info.status = SubmitRequirementExpressionInfo.Status.valueOf(result.status().name());
     info.passingAtoms = hide ? null : result.passingAtoms();
     info.failingAtoms = hide ? null : result.failingAtoms();
+    info.errorMessage = result.errorMessage().isPresent() ? result.errorMessage().get() : null;
     return info;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ValidationOptionsUtil.java b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
new file mode 100644
index 0000000..137239c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+
+/** Utilities for validation options parsing. */
+public final class ValidationOptionsUtil {
+  public static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
+  private ValidationOptionsUtil() {}
+}
diff --git a/java/com/google/gerrit/server/change/VoteResource.java b/java/com/google/gerrit/server/change/VoteResource.java
index 27b5bec..3f5ec4c 100644
--- a/java/com/google/gerrit/server/change/VoteResource.java
+++ b/java/com/google/gerrit/server/change/VoteResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class VoteResource implements RestResource {
-  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND =
-      new TypeLiteral<RestView<VoteResource>>() {};
+  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND = new TypeLiteral<>() {};
 
   private final ReviewerResource reviewer;
   private final String label;
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index 816a904..44a3d16 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -112,8 +112,8 @@
     return Iterables.concat(sortedByProject);
   }
 
-  private List<PatchSetData> sortProject(Project.NameKey project, Collection<ChangeData> in)
-      throws IOException {
+  private ImmutableList<PatchSetData> sortProject(
+      Project.NameKey project, Collection<ChangeData> in) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(retainBody);
@@ -181,7 +181,7 @@
           }
         }
       }
-      return result;
+      return ImmutableList.copyOf(result);
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 1409170..04fd1c0 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -19,21 +19,18 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-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.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -61,8 +58,8 @@
   private final WorkInProgressStateChanged stateChanged;
 
   private boolean sendEmail = true;
+  private ObjectId preUpdateMetaId;
   private Change change;
-  private ChangeNotes notes;
   private PatchSet ps;
   private String mailMessage;
 
@@ -88,8 +85,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx) {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
-    notes = ctx.getNotes();
     ps = psUtil.get(ctx.getNotes(), change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setWorkInProgress(workInProgress);
@@ -131,25 +128,15 @@
         || !sendEmail) {
       return;
     }
-    RepoView repoView;
-    try {
-      repoView = ctx.getRepoView();
-    } catch (IOException ex) {
-      throw new StorageException(
-          String.format("Repository %s not found", ctx.getProject().get()), ex);
-    }
     email
         .create(
-            notify,
-            notes,
+            ctx,
             ps,
-            ctx.getIdentifiedUser(),
-            mailMessage,
-            ctx.getWhen(),
-            ImmutableList.of(),
+            preUpdateMetaId,
             mailMessage,
             ImmutableList.of(),
-            repoView)
+            mailMessage,
+            ImmutableList.of())
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
index af2ae92..ce9aa78 100644
--- a/java/com/google/gerrit/server/comment/CommentContextKey.java
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.comment;
 
 import com.google.auto.value.AutoValue;
diff --git a/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
index 27ae41f..0e377d3 100644
--- a/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 java.util.Optional;
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index 1760378..b6ffcee 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -65,6 +65,7 @@
   private final SignedToken emailReg;
   private final boolean allowRegisterNewEmail;
   private final boolean userNameCaseInsensitive;
+  private final boolean userNameCaseInsensitiveMigrationMode;
   private GitBasicAuthPolicy gitBasicAuthPolicy;
 
   @Inject
@@ -97,6 +98,8 @@
     userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
     allowRegisterNewEmail = cfg.getBoolean("auth", "allowRegisterNewEmail", true);
     userNameCaseInsensitive = cfg.getBoolean("auth", "userNameCaseInsensitive", false);
+    userNameCaseInsensitiveMigrationMode =
+        cfg.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
 
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP
         && authType != AuthType.LDAP
@@ -244,6 +247,11 @@
     return userNameCaseInsensitive;
   }
 
+  /** Whether user name case insensitive migration is in progress */
+  public boolean isUserNameCaseInsensitiveMigrationMode() {
+    return userNameCaseInsensitiveMigrationMode;
+  }
+
   public GitBasicAuthPolicy getGitBasicAuthPolicy() {
     return gitBasicAuthPolicy;
   }
diff --git a/java/com/google/gerrit/server/config/CacheResource.java b/java/com/google/gerrit/server/config/CacheResource.java
index 7a835b1..b2f790c 100644
--- a/java/com/google/gerrit/server/config/CacheResource.java
+++ b/java/com/google/gerrit/server/config/CacheResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class CacheResource extends ConfigResource {
-  public static final TypeLiteral<RestView<CacheResource>> CACHE_KIND =
-      new TypeLiteral<RestView<CacheResource>>() {};
+  public static final TypeLiteral<RestView<CacheResource>> CACHE_KIND = new TypeLiteral<>() {};
 
   private final String name;
   private final Provider<Cache<?, ?>> cacheProvider;
diff --git a/java/com/google/gerrit/server/config/CapabilityResource.java b/java/com/google/gerrit/server/config/CapabilityResource.java
index 7e3c87e..5a1977b 100644
--- a/java/com/google/gerrit/server/config/CapabilityResource.java
+++ b/java/com/google/gerrit/server/config/CapabilityResource.java
@@ -19,5 +19,5 @@
 
 public class CapabilityResource extends ConfigResource {
   public static final TypeLiteral<RestView<CapabilityResource>> CAPABILITY_KIND =
-      new TypeLiteral<RestView<CapabilityResource>>() {};
+      new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/config/ConfigResource.java b/java/com/google/gerrit/server/config/ConfigResource.java
index f2b7c8e..93efd19 100644
--- a/java/com/google/gerrit/server/config/ConfigResource.java
+++ b/java/com/google/gerrit/server/config/ConfigResource.java
@@ -21,8 +21,7 @@
 import java.util.concurrent.TimeUnit;
 
 public class ConfigResource implements RestResource {
-  public static final TypeLiteral<RestView<ConfigResource>> CONFIG_KIND =
-      new TypeLiteral<RestView<ConfigResource>>() {};
+  public static final TypeLiteral<RestView<ConfigResource>> CONFIG_KIND = new TypeLiteral<>() {};
 
   /**
    * Default cache control that gets set on the 'Cache-Control' header for responses on this
diff --git a/java/com/google/gerrit/server/config/ConfigSection.java b/java/com/google/gerrit/server/config/ConfigSection.java
deleted file mode 100644
index 057ce99..0000000
--- a/java/com/google/gerrit/server/config/ConfigSection.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.config;
-
-import org.eclipse.jgit.lib.Config;
-
-/** Provides access to one section from {@link Config} */
-public class ConfigSection {
-
-  private final Config cfg;
-  private final String section;
-
-  public ConfigSection(Config cfg, String section) {
-    this.cfg = cfg;
-    this.section = section;
-  }
-
-  public String optional(String name) {
-    return cfg.getString(section, null, name);
-  }
-
-  public String required(String name) {
-    return ConfigUtil.getRequired(cfg, section, name);
-  }
-}
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index 4032e63..7fd075e 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -20,7 +20,7 @@
 import java.util.LinkedHashSet;
 import java.util.Objects;
 import java.util.Set;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.lib.Config;
 
 /**
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index c44b0fd..5d94255 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -145,7 +145,7 @@
             list.add(getEnum(section, subsection, setting, string, all));
           } catch (IllegalArgumentException ex) {
             // It's better to ignore a wrongly configured enum, rather than fail to load Gerrit.
-            logger.atWarning().log(ex.getMessage());
+            logger.atWarning().log("%s", ex.getMessage());
           }
         }
       }
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 00df1e6..d581675 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.server.change.ArchiveFormatInternal;
@@ -23,7 +24,6 @@
 import com.google.inject.Singleton;
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
-import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
@@ -43,23 +43,24 @@
   private final ImmutableSet<ArchiveFormatInternal> archiveFormats;
 
   @Inject
-  DownloadConfig(@GerritServerConfig Config cfg) {
+  public DownloadConfig(@GerritServerConfig Config cfg) {
     String[] allSchemes = cfg.getStringList("download", null, "scheme");
     if (allSchemes.length == 0) {
       downloadSchemes =
           ImmutableSet.of(
               CoreDownloadSchemes.SSH, CoreDownloadSchemes.HTTP, CoreDownloadSchemes.ANON_HTTP);
     } else {
-      List<String> normalized = new ArrayList<>(allSchemes.length);
+      ImmutableSet.Builder<String> normalized =
+          ImmutableSet.builderWithExpectedSize(allSchemes.length);
       for (String s : allSchemes) {
         String core = toCoreScheme(s);
         if (core == null) {
-          logger.atWarning().log("not a core download scheme: " + s);
+          logger.atWarning().log("not a core download scheme: %s", s);
           continue;
         }
         normalized.add(core);
       }
-      downloadSchemes = ImmutableSet.copyOf(normalized);
+      downloadSchemes = normalized.build();
     }
 
     DownloadCommand[] downloadCommandValues = DownloadCommand.values();
@@ -87,6 +88,7 @@
     return list.size() == 1 && list.get(0) == null;
   }
 
+  @Nullable
   private static String toCoreScheme(String s) {
     try {
       Field f = CoreDownloadSchemes.class.getField(s.toUpperCase());
diff --git a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
index ebb0e50..db21e1f 100644
--- a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.annotations.VisibleForTesting;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 24882cb..f442500 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Ticker;
 import com.google.common.cache.Cache;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -36,6 +37,7 @@
 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.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
@@ -44,6 +46,7 @@
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
@@ -107,7 +110,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
-import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -120,6 +122,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -129,11 +132,11 @@
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.NotesBranchUtil;
@@ -172,6 +175,7 @@
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -187,14 +191,17 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.query.change.DistinctVotersPredicate;
+import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -249,7 +256,6 @@
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
     install(AccountCacheImpl.module());
-    install(ApprovalCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
@@ -279,7 +285,7 @@
     install(new GroupDbModule());
     install(new GroupModule());
     install(new NoteDbModule());
-    install(new PrologModule());
+    install(new PrologModule(cfg));
     install(new DefaultSubmitRuleModule());
     install(new IgnoreSelfApprovalRuleModule());
     install(new ReceiveCommitsModule());
@@ -293,8 +299,10 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
+    factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
-    factory(MergeUtil.Factory.class);
+    factory(EmailNewPatchSet.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
@@ -309,6 +317,7 @@
     bind(AccountDefaultDisplayName.class).toInstance(accountDefaultDisplayName);
     factory(ProjectOwnerGroupsProvider.Factory.class);
     factory(SubmitRuleEvaluator.Factory.class);
+    factory(DeleteZombieCommentsRefs.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -350,6 +359,7 @@
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
     DynamicSet.setOf(binder(), ChangeDeletedListener.class);
@@ -385,7 +395,7 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
+    DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReindexAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
@@ -393,7 +403,7 @@
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.bind(binder(), CommitValidationListener.class)
-        .to(SubmitRequirementExpressionsValidator.class);
+        .to(SubmitRequirementConfigValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -436,9 +446,12 @@
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
-    DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
+    if (cfg.getBoolean("tracing", "exportPerformanceMetrics", false)) {
+      DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
+    }
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
     DynamicSet.setOf(binder(), ChangeETagComputation.class);
@@ -447,6 +460,7 @@
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
     DynamicSet.setOf(binder(), OnPostReview.class);
     DynamicMap.mapOf(binder(), AccountTagProvider.class);
+    DynamicSet.setOf(binder(), AttentionSetListener.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -489,6 +503,7 @@
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
     factory(StoreSubmitRequirementsOp.Factory.class);
+    factory(FileEditsPredicate.Factory.class);
 
     bind(AccountManager.class);
     bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
@@ -500,5 +515,7 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
     DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+
+    bind(AttentionSetObserver.class);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIds.java b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
new file mode 100644
index 0000000..c47d3be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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;
+
+/**
+ * List of ServerIds of the Gerrit data imported from other servers.
+ *
+ * <p>This values correspond to the {@code GerritServerId} of other servers.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritImportedServerIds {}
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
new file mode 100644
index 0000000..f3f7645
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class GerritImportedServerIdsProvider implements Provider<ImmutableSet<String>> {
+  public static final String SECTION = "gerrit";
+  public static final String KEY = "importedServerId";
+
+  private final ImmutableSet<String> importedIds;
+
+  @Inject
+  public GerritImportedServerIdsProvider(@GerritServerConfig Config cfg) {
+    importedIds = ImmutableSet.copyOf(cfg.getStringList(SECTION, null, KEY));
+  }
+
+  @Override
+  public ImmutableSet<String> get() {
+    return importedIds;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 5632978..0a213b4 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.extensions.webui.BranchWebLink;
-import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
@@ -73,7 +72,6 @@
         }
 
         if (!isNullOrEmpty(type.getFile()) || !isNullOrEmpty(type.getRootTree())) {
-          DynamicSet.bind(binder(), EditWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), FileWebLink.class).to(GitwebLinks.class);
         }
 
@@ -101,6 +99,7 @@
     return values.length > 0 && isNullOrEmpty(values[0]);
   }
 
+  @Nullable
   private static GitwebType typeFromConfig(Config cfg) {
     GitwebType defaultType = defaultType(cfg.getString("gitweb", null, "type"));
     if (defaultType == null) {
@@ -138,6 +137,7 @@
     return type;
   }
 
+  @Nullable
   private static GitwebType defaultType(String typeName) {
     GitwebType type = new GitwebType();
     switch (nullToEmpty(typeName)) {
@@ -146,7 +146,7 @@
         type.setProject("?p=${project}.git;a=summary");
         type.setRevision("?p=${project}.git;a=commit;h=${commit}");
         type.setBranch("?p=${project}.git;a=shortlog;h=${branch}");
-        type.setTag("?p=${project}.git;a=tag;h=${tag}");
+        type.setTag("?p=${project}.git;a=shortlog;h=${tag}");
         type.setRootTree("?p=${project}.git;a=tree;hb=${commit}");
         type.setFile("?p=${project}.git;hb=${commit};f=${file}");
         type.setFileHistory("?p=${project}.git;a=history;hb=${branch};f=${file}");
@@ -257,7 +257,6 @@
   @Singleton
   static class GitwebLinks
       implements BranchWebLink,
-          EditWebLink,
           FileHistoryWebLink,
           FileWebLink,
           PatchSetWebLink,
@@ -286,6 +285,7 @@
       this.tag = parse(type.getTag());
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getBranchWebLink(String projectName, String branchName) {
       if (branch != null) {
@@ -298,6 +298,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getTagWebLink(String projectName, String tagName) {
       if (tag != null) {
@@ -307,6 +308,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getFileHistoryWebLink(String projectName, String revision, String fileName) {
       if (fileHistory != null) {
@@ -320,24 +322,22 @@
       return null;
     }
 
+    @Nullable
     @Override
-    public WebLinkInfo getFileWebLink(String projectName, String revision, String fileName) {
+    public WebLinkInfo getFileWebLink(
+        String projectName, String revision, String hash, String fileName) {
       if (file != null) {
         return link(
             file.replace("project", encode(projectName))
                 .replace("commit", encode(revision))
+                .replace("hash", encode(hash))
                 .replace("file", encode(fileName))
                 .toString());
       }
       return null;
     }
 
-    @Override
-    public WebLinkInfo getEditWebLink(String projectName, String revision, String fileName) {
-      // For Gitweb treat edit links the same as file links
-      return getFileWebLink(projectName, revision, fileName);
-    }
-
+    @Nullable
     @Override
     public WebLinkInfo getPatchSetWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
@@ -366,6 +366,7 @@
       return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getProjectWeblink(String projectName) {
       if (project != null) {
@@ -385,6 +386,7 @@
       return new WebLinkInfo(type.getLinkName(), null, url + rest, null);
     }
 
+    @Nullable
     private static ParameterizedString parse(String pattern) {
       if (!isNullOrEmpty(pattern)) {
         return new ParameterizedString(pattern);
diff --git a/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java b/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java
new file mode 100644
index 0000000..ac3c53a
--- /dev/null
+++ b/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.metrics.MetricsReservoirConfig;
+import com.google.gerrit.metrics.ReservoirType;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/** Define metrics reservoir settings based on gerrit.config */
+@Singleton
+public class MetricsReservoirConfigImpl implements MetricsReservoirConfig {
+  private static final double RESERVOIR_ALPHA_DEFAULT = 0.015;
+  private static final int METRICS_RESERVOIR_SIZE_DEFAULT = 1028;
+  private static final long METRICS_RESERVOIR_WINDOW_MSEC_DEFAULT = 60000L;
+  private static final String METRICS_SECTION = "metrics";
+  private static final String METRICS_RESERVOIR = "reservoir";
+
+  private final ReservoirType reservoirType;
+
+  private final Duration reservoirWindow;
+  private final int reservoirSize;
+  private final double reservoirAlpha;
+
+  @Inject
+  MetricsReservoirConfigImpl(@GerritServerConfig Config gerritConfig) {
+    this.reservoirType =
+        gerritConfig.getEnum(
+            METRICS_SECTION, null, METRICS_RESERVOIR, ReservoirType.ExponentiallyDecaying);
+
+    reservoirWindow =
+        Duration.ofMillis(
+            ConfigUtil.getTimeUnit(
+                gerritConfig,
+                METRICS_SECTION,
+                reservoirType.name(),
+                "window",
+                METRICS_RESERVOIR_WINDOW_MSEC_DEFAULT,
+                TimeUnit.MILLISECONDS));
+    reservoirSize =
+        gerritConfig.getInt(
+            METRICS_SECTION, reservoirType.name(), "size", METRICS_RESERVOIR_SIZE_DEFAULT);
+    reservoirAlpha =
+        Optional.ofNullable(gerritConfig.getString(METRICS_SECTION, reservoirType.name(), "alpha"))
+            .map(Double::parseDouble)
+            .orElse(RESERVOIR_ALPHA_DEFAULT);
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirType()
+   */
+  @Override
+  public ReservoirType reservoirType() {
+    return reservoirType;
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirWindow()
+   */
+  @Override
+  public Duration reservoirWindow() {
+    return reservoirWindow;
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirSize()
+   */
+  @Override
+  public int reservoirSize() {
+    return reservoirSize;
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirAlpha()
+   */
+  @Override
+  public double reservoirAlpha() {
+    return reservoirAlpha;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index 69d75be..fbdb324 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -182,6 +182,7 @@
       my.add(new MenuItem("Edits", "#/q/has:edit", null));
       my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
       my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("All Visible Changes", "#/q/is:visible", null));
       my.add(new MenuItem("Groups", "#/settings/#Groups", null));
     }
     return my;
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index c09988e3..e11d6aa 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
@@ -360,6 +361,7 @@
       }
     }
 
+    @Nullable
     private ProjectConfig parseConfig(Project.NameKey p, String idStr)
         throws IOException, ConfigInvalidException, RepositoryNotFoundException {
       ObjectId id = ObjectId.fromString(idStr);
@@ -382,14 +384,17 @@
     }
   }
 
+  @Nullable
   private static Boolean toBoolean(String value) {
     return value != null ? Boolean.parseBoolean(value) : null;
   }
 
+  @Nullable
   private static Integer toInt(String value) {
     return value != null ? Integer.parseInt(value) : null;
   }
 
+  @Nullable
   private static Long toLong(String value) {
     return value != null ? Long.parseLong(value) : null;
   }
diff --git a/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
index f722321..d569c87 100644
--- a/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -55,6 +55,7 @@
         cfg.getStringList(SECTION_NAME, findSubSection(project.get()), OWNER_GROUP_NAME));
   }
 
+  @Nullable
   public Path getBasePath(Project.NameKey project) {
     String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()), BASE_PATH_NAME);
     return basePath != null ? Paths.get(basePath) : null;
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 5e268da..2cd24ba 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -140,6 +141,7 @@
    * @param path the path string to resolve. May be null.
    * @return the resolved path; null if {@code path} was null or empty.
    */
+  @Nullable
   public Path resolve(String path) {
     if (path != null && !path.isEmpty()) {
       Path loc = site_path.resolve(path).normalize();
diff --git a/java/com/google/gerrit/server/config/SshClientImplementation.java b/java/com/google/gerrit/server/config/SshClientImplementation.java
deleted file mode 100644
index 5811e4d..0000000
--- a/java/com/google/gerrit/server/config/SshClientImplementation.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.base.Preconditions.checkArgument;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Enums;
-import com.google.common.base.Strings;
-
-/* SSH implementation to use by JGit SSH client transport protocol. */
-public enum SshClientImplementation {
-  /** JCraft JSch implementation. */
-  JSCH,
-
-  /** Apache MINA implementation. */
-  APACHE;
-
-  private static final String ENV_VAR = "SSH_CLIENT_IMPLEMENTATION";
-  private static final String SYS_PROP = "gerrit.sshClientImplementation";
-
-  @VisibleForTesting
-  public static SshClientImplementation getFromEnvironment() {
-    String value = System.getenv(ENV_VAR);
-    if (Strings.isNullOrEmpty(value)) {
-      value = System.getProperty(SYS_PROP);
-    }
-    if (Strings.isNullOrEmpty(value)) {
-      return APACHE;
-    }
-    SshClientImplementation client =
-        Enums.getIfPresent(SshClientImplementation.class, value).orNull();
-    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
-      checkArgument(
-          client != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
-    } else {
-      checkArgument(
-          client != null,
-          "Invalid value for system property %s: %s",
-          SYS_PROP,
-          System.getProperty(SYS_PROP));
-    }
-    return client;
-  }
-
-  public boolean isMina() {
-    return this == APACHE;
-  }
-}
diff --git a/java/com/google/gerrit/server/config/TaskResource.java b/java/com/google/gerrit/server/config/TaskResource.java
index 7b69533..dac455f 100644
--- a/java/com/google/gerrit/server/config/TaskResource.java
+++ b/java/com/google/gerrit/server/config/TaskResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class TaskResource extends ConfigResource {
-  public static final TypeLiteral<RestView<TaskResource>> TASK_KIND =
-      new TypeLiteral<RestView<TaskResource>>() {};
+  public static final TypeLiteral<RestView<TaskResource>> TASK_KIND = new TypeLiteral<>() {};
 
   private final Task<?> task;
 
diff --git a/java/com/google/gerrit/server/config/TopMenuResource.java b/java/com/google/gerrit/server/config/TopMenuResource.java
index bca6331..f5c71ed 100644
--- a/java/com/google/gerrit/server/config/TopMenuResource.java
+++ b/java/com/google/gerrit/server/config/TopMenuResource.java
@@ -18,6 +18,5 @@
 import com.google.inject.TypeLiteral;
 
 public class TopMenuResource extends ConfigResource {
-  public static final TypeLiteral<RestView<TopMenuResource>> TOP_MENU_KIND =
-      new TypeLiteral<RestView<TopMenuResource>>() {};
+  public static final TypeLiteral<RestView<TopMenuResource>> TOP_MENU_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index 1611da9..778ab4c 100644
--- a/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -32,7 +32,7 @@
 public class TrackingFootersProvider implements Provider<TrackingFooters> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final int MAX_LENGTH = 10;
+  private static final int MAX_LENGTH = 20;
 
   private static final String TRACKING_ID_TAG = "trackingid";
   private static final String FOOTER_TAG = "footer";
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 5054da6..04ea438 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -83,6 +83,12 @@
     return getWebUrl().map(url -> url + "Documentation/" + page + "#" + anchor);
   }
 
+  /** Returns a URL pointing to a plugin documentation page, at a given named anchor. */
+  default Optional<String> getPluginDocUrl(String pluginName, String page, String anchor) {
+    return getWebUrl()
+        .map(url -> url + "plugins/" + pluginName + "/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/AccountAttribute.java b/java/com/google/gerrit/server/data/AccountAttribute.java
index 19605a2..9be221b 100644
--- a/java/com/google/gerrit/server/data/AccountAttribute.java
+++ b/java/com/google/gerrit/server/data/AccountAttribute.java
@@ -18,4 +18,11 @@
   public String name;
   public String email;
   public String username;
+  public Integer accountId;
+
+  public AccountAttribute(Integer id) {
+    this.accountId = id;
+  }
+
+  public AccountAttribute() {}
 }
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index d71f83e..0e911b9 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,6 +21,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.vladsch.flexmark.ast.Heading;
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
@@ -36,7 +37,7 @@
 import java.net.URL;
 import java.nio.charset.Charset;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.apache.commons.lang.StringEscapeUtils;
+import org.apache.commons.text.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
 
@@ -78,12 +79,12 @@
   }
 
   public MarkdownFormatter setCss(String css) {
-    this.css = StringEscapeUtils.escapeHtml(css);
+    this.css = StringEscapeUtils.escapeHtml4(css);
     return this;
   }
 
   private MutableDataHolder markDownOptions() {
-    int options = ALL & ~(HARDWRAPS);
+    int options = ALL & ~HARDWRAPS;
     if (suppressHtml) {
       options |= SUPPRESS_ALL_HTML;
     }
@@ -126,6 +127,7 @@
     return findTitle(parseMarkdown(md));
   }
 
+  @Nullable
   private String findTitle(Node root) {
     if (root instanceof Heading) {
       Heading h = (Heading) root;
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 59ae6f8..cd49ea6 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -33,9 +34,9 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IndexOutput;
-import org.apache.lucene.store.RAMDirectory;
 
 @Singleton
 public class QueryDocumentationExecutor {
@@ -99,8 +100,9 @@
     }
   }
 
+  @Nullable
   protected Directory readIndexDirectory() throws IOException {
-    Directory dir = new RAMDirectory();
+    Directory dir = new ByteBuffersDirectory();
     byte[] buffer = new byte[4096];
     InputStream index = getClass().getResourceAsStream(Constants.INDEX_ZIP);
     if (index == null) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index bc905c2..903a4c0 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.base.Charsets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -52,11 +53,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
 import org.eclipse.jgit.diff.RawText;
@@ -91,7 +92,7 @@
 @Singleton
 public class ChangeEditModifier {
 
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
@@ -110,12 +111,12 @@
       ProjectCache projectCache) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
 
-    noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
+    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser);
   }
 
   /**
@@ -139,7 +140,7 @@
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
     ObjectId patchSetCommitId = currentPatchSet.commitId();
-    noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+    noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.now());
   }
 
   /**
@@ -187,7 +188,7 @@
     RevTree basePatchSetTree = basePatchSetCommit.getTree();
 
     ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
-    Timestamp nowTimestamp = TimeUtil.nowTs();
+    Instant nowTimestamp = TimeUtil.now();
     String commitMessage = currentEditCommit.getFullMessage();
     ObjectId newEditCommitId =
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
@@ -226,13 +227,18 @@
    * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
    * @param filePath the path of the file whose contents should be modified
    * @param newContent the new file content
+   * @param newGitFileMode the new file mode in octal format. {@code null} indicates no change
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
    * @throws InvalidChangeOperationException if the file already had the specified content
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void modifyFile(
-      Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
+      Repository repository,
+      ChangeNotes notes,
+      String filePath,
+      RawInput newContent,
+      @Nullable Integer newGitFileMode)
       throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     modifyCommit(
@@ -240,7 +246,8 @@
         notes,
         new ModificationIntention.LatestCommit(),
         CommitModification.builder()
-            .addTreeModification(new ChangeFileContentModification(filePath, newContent))
+            .addTreeModification(
+                new ChangeFileContentModification(filePath, newContent, newGitFileMode))
             .build());
   }
 
@@ -385,7 +392,7 @@
       return unmodifiedEdit.get();
     }
 
-    Timestamp nowTimestamp = TimeUtil.nowTs();
+    Instant nowTimestamp = TimeUtil.now();
     ObjectId newEditCommit =
         createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
 
@@ -407,14 +414,15 @@
 
     // Not allowed to edit if the current patch set is locked.
     patchSetUtil.checkPatchSetNotLocked(notes);
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.ADD_PATCH_SET);
-      projectCache
-          .get(notes.getProjectName())
-          .orElseThrow(illegalState(notes.getProjectName()))
-          .checkStatePermitsWrite();
-    } catch (AuthException denied) {
-      throw new AuthException("edit not permitted", denied);
+    boolean canEdit =
+        permissionBackend.currentUser().change(notes).test(ChangePermission.ADD_PATCH_SET);
+    canEdit &=
+        projectCache
+            .get(notes.getProjectName())
+            .orElseThrow(illegalState(notes.getProjectName()))
+            .statePermitsWrite();
+    if (!canEdit) {
+      throw new AuthException("edit not permitted");
     }
   }
 
@@ -501,7 +509,7 @@
       RevCommit basePatchsetCommit,
       ObjectId tree,
       String commitMessage,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException {
     try (ObjectInserter objectInserter = repository.newObjectInserter()) {
       CommitBuilder builder = new CommitBuilder();
@@ -516,9 +524,9 @@
     }
   }
 
-  private PersonIdent getCommitterIdent(Timestamp commitTimestamp) {
+  private PersonIdent getCommitterIdent(Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, tz);
+    return user.newCommitterIdent(commitTimestamp, zoneId);
   }
 
   /**
@@ -547,7 +555,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException;
   }
 
@@ -647,7 +655,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.updateEdit(
           notes.getProjectName(), repository, changeEdit, newEditCommitId, timestamp);
@@ -701,19 +709,19 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.createEdit(repository, notes, basePatchSet, newEditCommitId, timestamp);
     }
   }
 
   private static class NoteDbEdits {
-    private final TimeZone tz;
+    private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
 
-    NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
-      this.tz = tz;
+    NoteDbEdits(ZoneId zoneId, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+      this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
     }
@@ -723,7 +731,7 @@
         ChangeNotes notes,
         PatchSet basePatchset,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       Change change = notes.getChange();
       String editRefName = getEditRefName(change, basePatchset);
@@ -750,7 +758,7 @@
         Repository repository,
         ChangeEdit changeEdit,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       String editRefName = changeEdit.getRefName();
       RevCommit currentEditCommit = changeEdit.getEditCommit();
@@ -769,7 +777,7 @@
         String refName,
         ObjectId currentObjectId,
         ObjectId targetObjectId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       RefUpdate ru = repository.updateRef(refName);
       ru.setExpectedOldObjectId(currentObjectId);
@@ -795,7 +803,7 @@
         PatchSet currentPatchSet,
         ObjectId currentEditCommit,
         ObjectId newEditCommitId,
-        Timestamp nowTimestamp)
+        Instant nowTimestamp)
         throws IOException {
       String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
       updateReferenceWithNameChange(
@@ -814,7 +822,7 @@
         ObjectId currentObjectId,
         String newRefName,
         ObjectId targetObjectId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
       batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
@@ -838,9 +846,9 @@
       }
     }
 
-    private PersonIdent getRefLogIdent(Timestamp timestamp) {
+    private PersonIdent getRefLogIdent(Instant timestamp) {
       IdentifiedUser user = currentUser.get().asIdentifiedUser();
-      return user.newRefLogIdent(timestamp, tz);
+      return user.newRefLogIdent(timestamp, zoneId);
     }
 
     private void reindex(Change change) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6b018ce..74834ab 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -185,7 +185,7 @@
         message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
       }
 
-      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.nowTs())) {
+      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.now())) {
         bu.setRepository(repo, rw, oi);
         bu.setNotify(notify);
         bu.addOp(change.getId(), inserter.setMessage(message.toString()));
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 39ab041..96c6685 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
 import java.io.InputStream;
@@ -42,16 +43,26 @@
 
   private final String filePath;
   private final RawInput newContent;
+  private final Integer newGitFileMode;
 
   public ChangeFileContentModification(String filePath, RawInput newContent) {
     this.filePath = filePath;
     this.newContent = requireNonNull(newContent, "new content required");
+    this.newGitFileMode = null;
+  }
+
+  public ChangeFileContentModification(
+      String filePath, RawInput newContent, @Nullable Integer newGitFileMode) {
+    this.filePath = filePath;
+    this.newContent = requireNonNull(newContent, "new content required");
+    this.newGitFileMode = newGitFileMode;
   }
 
   @Override
   public List<DirCacheEditor.PathEdit> getPathEdits(
       Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
-    DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
+    DirCacheEditor.PathEdit changeContentEdit =
+        new ChangeContent(filePath, newContent, repository, newGitFileMode);
     return Collections.singletonList(changeContentEdit);
   }
 
@@ -70,16 +81,32 @@
 
     private final RawInput newContent;
     private final Repository repository;
+    private final Integer newGitFileMode;
 
-    ChangeContent(String filePath, RawInput newContent, Repository repository) {
+    ChangeContent(
+        String filePath,
+        RawInput newContent,
+        Repository repository,
+        @Nullable Integer newGitFileMode) {
       super(filePath);
       this.newContent = newContent;
       this.repository = repository;
+      this.newGitFileMode = newGitFileMode;
+    }
+
+    private boolean isValidGitFileMode(int gitFileMode) {
+      return (gitFileMode == 100755) || (gitFileMode == 100644);
     }
 
     @Override
     public void apply(DirCacheEntry dirCacheEntry) {
       try {
+        if (newGitFileMode != null && newGitFileMode != 0) {
+          if (!isValidGitFileMode(newGitFileMode)) {
+            throw new IllegalStateException("GitFileMode " + newGitFileMode + " is invalid");
+          }
+          dirCacheEntry.setFileMode(FileMode.fromBits(newGitFileMode));
+        }
         if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
           dirCacheEntry.setLength(0);
           dirCacheEntry.setLastModified(Instant.EPOCH);
@@ -98,7 +125,7 @@
       } catch (IOException e) {
         String message =
             String.format("Could not change the content of %s", dirCacheEntry.getPathString());
-        logger.atSevere().withCause(e).log(message);
+        logger.atSevere().withCause(e).log("%s", message);
       } catch (InvalidObjectIdException e) {
         logger.atSevere().withCause(e).log("Invalid object id in submodule link");
       }
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 4001a48..2697da5 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritInstanceId;
@@ -170,9 +169,8 @@
         return false;
       }
 
-      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
+      return permissionBackend.user(user).project(project).test(ProjectPermission.ACCESS);
+    } catch (PermissionBackendException e) {
       return false;
     }
   }
@@ -185,15 +183,10 @@
     if (!pe.isPresent() || !pe.get().statePermitsRead()) {
       return false;
     }
-    try {
-      permissionBackend
-          .user(user)
-          .change(notesFactory.createChecked(change))
-          .check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend
+        .user(user)
+        .change(notesFactory.createChecked(change))
+        .test(ChangePermission.READ);
   }
 
   protected boolean isVisibleTo(BranchNameKey branchName, CurrentUser user)
@@ -203,12 +196,7 @@
       return false;
     }
 
-    try {
-      permissionBackend.user(user).ref(branchName).check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
   }
 
   protected boolean isVisibleTo(Event event, CurrentUser user) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index a7fea3c..cd7e29a 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -20,6 +20,7 @@
 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.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -36,6 +37,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountAttributeLoader;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
@@ -58,6 +60,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -120,7 +123,7 @@
     this.accountTemplateUtil = accountTemplateUtil;
   }
 
-  public ChangeAttribute asChangeAttribute(Change change) {
+  public ChangeAttribute asChangeAttribute(Change change, AccountAttributeLoader accountLoader) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
     a.branch = change.getDest().shortName();
@@ -128,17 +131,11 @@
     a.id = change.getKey().get();
     a.number = change.getId().get();
     a.subject = change.getSubject();
-    try {
-      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);
-    }
     a.url = getChangeUrl(change);
-    a.owner = asAccountAttribute(change.getOwner());
-    a.assignee = asAccountAttribute(change.getAssignee());
+    a.owner = asAccountAttribute(change.getOwner(), accountLoader);
+    a.assignee = asAccountAttribute(change.getAssignee(), accountLoader);
     a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
     a.cherryPickOfChange =
@@ -150,12 +147,9 @@
 
   /** Create a {@link ChangeAttribute} instance from the specified change. */
   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);
-    }
+    ChangeAttribute a = asChangeAttribute(change, (AccountAttributeLoader) null);
+    addHashTags(a, notes);
+    addCommitMessage(a, notes);
     return a;
   }
   /**
@@ -174,30 +168,32 @@
 
   /** Extend the existing {@link ChangeAttribute} with additional fields. */
   public void extend(ChangeAttribute a, Change change) {
-    a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
+    a.lastUpdated = change.getLastUpdatedOn().getEpochSecond();
     a.open = change.isNew();
   }
 
   /** Add allReviewers to an existing {@link ChangeAttribute}. */
-  public void addAllReviewers(ChangeAttribute a, ChangeNotes notes) {
+  public void addAllReviewers(
+      ChangeAttribute a, ChangeNotes notes, AccountAttributeLoader accountLoader) {
     Collection<Account.Id> reviewers = approvalsUtil.getReviewers(notes).all();
     if (!reviewers.isEmpty()) {
       a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
       for (Account.Id id : reviewers) {
-        a.allReviewers.add(asAccountAttribute(id));
+        a.allReviewers.add(asAccountAttribute(id, accountLoader));
       }
     }
   }
 
   /** Add submitRecords to an existing {@link ChangeAttribute}. */
-  public void addSubmitRecords(ChangeAttribute ca, List<SubmitRecord> submitRecords) {
+  public void addSubmitRecords(
+      ChangeAttribute ca, List<SubmitRecord> submitRecords, AccountAttributeLoader accountLoader) {
     ca.submitRecords = new ArrayList<>();
 
     for (SubmitRecord submitRecord : submitRecords) {
       SubmitRecordAttribute sa = new SubmitRecordAttribute();
       sa.status = submitRecord.status.name();
       if (submitRecord.status != SubmitRecord.Status.RULE_ERROR) {
-        addSubmitRecordLabels(submitRecord, sa);
+        addSubmitRecordLabels(submitRecord, sa, accountLoader);
         addSubmitRecordRequirements(submitRecord, sa);
       }
       ca.submitRecords.add(sa);
@@ -208,7 +204,8 @@
     }
   }
 
-  private void addSubmitRecordLabels(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
+  private void addSubmitRecordLabels(
+      SubmitRecord submitRecord, SubmitRecordAttribute sa, AccountAttributeLoader accountLoader) {
     if (submitRecord.labels != null && !submitRecord.labels.isEmpty()) {
       sa.labels = new ArrayList<>();
       for (SubmitRecord.Label lbl : submitRecord.labels) {
@@ -216,7 +213,7 @@
         la.label = lbl.label;
         la.status = lbl.status.name();
         if (lbl.appliedBy != null) {
-          la.by = asAccountAttribute(lbl.appliedBy);
+          la.by = asAccountAttribute(lbl.appliedBy, accountLoader);
         }
         sa.labels.add(la);
       }
@@ -298,8 +295,8 @@
     // 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.groups())) {
+        InternalChangeQuery.byBranchGroups(
+            queryProvider, indexConfig, change.getDest(), currentPs.groups())) {
       PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
         RevCommit commit = rw.parseCommit(ps.commitId());
@@ -351,13 +348,23 @@
     a.commitMessage = commitMessage;
   }
 
+  private void addCommitMessage(ChangeAttribute changeAttribute, ChangeNotes notes) {
+    try {
+      addCommitMessage(changeAttribute, changeDataFactory.create(notes).commitMessage());
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log(
+          "Error while getting full commit message for change %d", changeAttribute.number);
+    }
+  }
+
   public void addPatchSets(
       RevWalk revWalk,
       ChangeAttribute ca,
       Collection<PatchSet> ps,
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
-      LabelTypes labelTypes) {
-    addPatchSets(revWalk, ca, ps, approvals, false, null, labelTypes);
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
+    addPatchSets(revWalk, ca, ps, approvals, false, null, labelTypes, accountLoader);
   }
 
   public void addPatchSets(
@@ -367,13 +374,14 @@
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
       boolean includeFiles,
       Change change,
-      LabelTypes labelTypes) {
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
     if (!ps.isEmpty()) {
       ca.patchSets = new ArrayList<>(ps.size());
       for (PatchSet p : ps) {
-        PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p);
+        PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p, accountLoader);
         if (approvals != null) {
-          addApprovals(psa, p.id(), approvals, labelTypes);
+          addApprovals(psa, p.id(), approvals, labelTypes, accountLoader);
         }
         ca.patchSets.add(psa);
         if (includeFiles) {
@@ -384,13 +392,15 @@
   }
 
   public void addPatchSetComments(
-      PatchSetAttribute patchSetAttribute, Collection<HumanComment> comments) {
+      PatchSetAttribute patchSetAttribute,
+      Collection<HumanComment> comments,
+      AccountAttributeLoader accountLoader) {
     for (HumanComment comment : comments) {
       if (comment.key.patchSetId == patchSetAttribute.number) {
         if (patchSetAttribute.comments == null) {
           patchSetAttribute.comments = new ArrayList<>();
         }
-        patchSetAttribute.comments.add(asPatchSetLineAttribute(comment));
+        patchSetAttribute.comments.add(asPatchSetLineAttribute(comment, accountLoader));
       }
     }
   }
@@ -400,7 +410,7 @@
     try {
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
       for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
@@ -420,23 +430,31 @@
     }
   }
 
-  public void addComments(ChangeAttribute ca, Collection<ChangeMessage> messages) {
+  public void addComments(
+      ChangeAttribute ca,
+      Collection<ChangeMessage> messages,
+      AccountAttributeLoader accountLoader) {
     if (!messages.isEmpty()) {
       ca.comments = new ArrayList<>();
       for (ChangeMessage message : messages) {
-        ca.comments.add(asMessageAttribute(message));
+        ca.comments.add(asMessageAttribute(message, accountLoader));
       }
     }
   }
 
-  /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */
   public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
+    return asPatchSetAttribute(revWalk, change, patchSet, null);
+  }
+
+  /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */
+  public PatchSetAttribute asPatchSetAttribute(
+      RevWalk revWalk, Change change, PatchSet patchSet, AccountAttributeLoader accountLoader) {
     PatchSetAttribute p = new PatchSetAttribute();
     p.revision = patchSet.commitId().name();
     p.number = patchSet.number();
     p.ref = patchSet.refName();
-    p.uploader = asAccountAttribute(patchSet.uploader());
-    p.createdOn = patchSet.createdOn().getTime() / 1000L;
+    p.uploader = asAccountAttribute(patchSet.uploader(), accountLoader);
+    p.createdOn = patchSet.createdOn().getEpochSecond();
     PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
@@ -452,12 +470,12 @@
         p.author.name = author.getName();
         p.author.username = "";
       } else {
-        p.author = asAccountAttribute(author.getAccount());
+        p.author = asAccountAttribute(author.getAccount(), accountLoader);
       }
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
@@ -475,20 +493,24 @@
       PatchSetAttribute p,
       PatchSet.Id id,
       Map<PatchSet.Id, Collection<PatchSetApproval>> all,
-      LabelTypes labelTypes) {
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
     Collection<PatchSetApproval> list = all.get(id);
     if (list != null) {
-      addApprovals(p, list, labelTypes);
+      addApprovals(p, list, labelTypes, accountLoader);
     }
   }
 
   public void addApprovals(
-      PatchSetAttribute p, Collection<PatchSetApproval> list, LabelTypes labelTypes) {
+      PatchSetAttribute p,
+      Collection<PatchSetApproval> list,
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
     if (!list.isEmpty()) {
       p.approvals = new ArrayList<>(list.size());
       for (PatchSetApproval a : list) {
         if (a.value() != 0) {
-          p.approvals.add(asApprovalAttribute(a, labelTypes));
+          p.approvals.add(asApprovalAttribute(a, labelTypes, accountLoader));
         }
       }
       if (p.approvals.isEmpty()) {
@@ -497,7 +519,12 @@
     }
   }
 
+  public AccountAttribute asAccountAttribute(Account.Id id, AccountAttributeLoader accountLoader) {
+    return accountLoader != null ? accountLoader.get(id) : asAccountAttribute(id);
+  }
+
   /** Create an AuthorAttribute for the given account suitable for serialization to JSON. */
+  @Nullable
   public AccountAttribute asAccountAttribute(Account.Id id) {
     if (id == null) {
       return null;
@@ -528,12 +555,13 @@
    * @param labelTypes label types for the containing project
    * @return object suitable for serialization to JSON
    */
-  public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
+  public ApprovalAttribute asApprovalAttribute(
+      PatchSetApproval approval, LabelTypes labelTypes, AccountAttributeLoader accountLoader) {
     ApprovalAttribute a = new ApprovalAttribute();
     a.type = approval.labelId().get();
     a.value = Short.toString(approval.value());
-    a.by = asAccountAttribute(approval.accountId());
-    a.grantedOn = approval.granted().getTime() / 1000L;
+    a.by = asAccountAttribute(approval.accountId(), accountLoader);
+    a.grantedOn = approval.granted().getEpochSecond();
     a.oldValue = null;
 
     Optional<LabelType> lt = labelTypes.byLabel(approval.labelId());
@@ -541,20 +569,22 @@
     return a;
   }
 
-  public MessageAttribute asMessageAttribute(ChangeMessage message) {
+  public MessageAttribute asMessageAttribute(
+      ChangeMessage message, AccountAttributeLoader accountLoader) {
     MessageAttribute a = new MessageAttribute();
-    a.timestamp = message.getWrittenOn().getTime() / 1000L;
+    a.timestamp = message.getWrittenOn().getEpochSecond();
     a.reviewer =
         message.getAuthor() != null
-            ? asAccountAttribute(message.getAuthor())
+            ? asAccountAttribute(message.getAuthor(), accountLoader)
             : asAccountAttribute(myIdent.get());
     a.message = accountTemplateUtil.replaceTemplates(message.getMessage());
     return a;
   }
 
-  public PatchSetCommentAttribute asPatchSetLineAttribute(HumanComment c) {
+  public PatchSetCommentAttribute asPatchSetLineAttribute(
+      HumanComment c, AccountAttributeLoader accountLoader) {
     PatchSetCommentAttribute a = new PatchSetCommentAttribute();
-    a.reviewer = asAccountAttribute(c.author.getId());
+    a.reviewer = asAccountAttribute(c.author.getId(), accountLoader);
     a.file = c.key.filename;
     a.line = c.lineNbr;
     a.message = c.message;
@@ -562,10 +592,19 @@
   }
 
   /** Get a link to the change; null if the server doesn't know its own address. */
+  @Nullable
   private String getChangeUrl(Change change) {
     if (change != null) {
       return urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId()).orElse(null);
     }
     return null;
   }
+
+  private void addHashTags(ChangeAttribute changeAttribute, ChangeNotes notes) {
+    Set<String> hashtags = notes.load().getHashtags();
+    if (!hashtags.isEmpty()) {
+      changeAttribute.hashtags = new ArrayList<>(hashtags.size());
+      changeAttribute.hashtags.addAll(hashtags);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index 27be2f3..bd784cf 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -29,8 +29,8 @@
         .registerTypeAdapter(Event.class, new EventDeserializer())
         .registerTypeAdapter(Supplier.class, new SupplierSerializer())
         .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
         .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
+        .registerTypeHierarchyAdapter(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 5498ec8..229ef86 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.collect.ImmutableMap;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -61,4 +62,15 @@
   public static Class<?> getClass(String type) {
     return typesByString.get(type);
   }
+
+  /**
+   * Get a copy of all currently registered events.
+   *
+   * <p>The key is the one given to the evenType parameter of the {@link #register} method.
+   *
+   * @return ImmutableMap of event types, Event classes.
+   */
+  public static Map<String, Class<?>> getRegisteredEvents() {
+    return ImmutableMap.copyOf(typesByString);
+  }
 }
diff --git a/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
new file mode 100644
index 0000000..90ed285
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+
+public class ProjectHeadUpdatedEvent extends ProjectEvent {
+
+  static final String TYPE = "project-head-updated";
+
+  public String projectName;
+  public String oldHead;
+  public String newHead;
+
+  public ProjectHeadUpdatedEvent() {
+    super(TYPE);
+  }
+
+  @Override
+  public NameKey getProjectNameKey() {
+    return Project.nameKey(projectName);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/RefReceivedEvent.java b/java/com/google/gerrit/server/events/RefReceivedEvent.java
index 18783aa..84e57dc 100644
--- a/java/com/google/gerrit/server/events/RefReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/RefReceivedEvent.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.server.events;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -22,6 +23,7 @@
   public ReceiveCommand command;
   public Project project;
   public IdentifiedUser user;
+  public ImmutableListMultimap<String, String> pushOptions;
 
   public RefReceivedEvent() {
     super(TYPE);
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index abacb85..18f3d7a 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Suppliers;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -39,6 +40,7 @@
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
@@ -86,7 +88,8 @@
         ReviewerDeletedListener,
         RevisionCreatedListener,
         TopicEditedListener,
-        VoteDeletedListener {
+        VoteDeletedListener,
+        HeadUpdatedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class StreamEventsApiListenerModule extends AbstractModule {
@@ -111,6 +114,7 @@
       DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
           .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HeadUpdatedListener.class).to(StreamEventsApiListener.class);
     }
   }
 
@@ -231,6 +235,7 @@
         });
   }
 
+  @Nullable
   String[] hashtagArray(Collection<String> hashtags) {
     if (hashtags != null && !hashtags.isEmpty()) {
       return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
@@ -339,6 +344,16 @@
   }
 
   @Override
+  public void onHeadUpdated(HeadUpdatedListener.Event ev) {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = ev.getProjectName();
+    event.oldHead = ev.getOldHeadName();
+    event.newHead = ev.getNewHeadName();
+
+    dispatcher.run(d -> d.postEvent(event.getProjectNameKey(), event));
+  }
+
+  @Override
   public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 65f8f2d..5a4580c 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,37 +22,12 @@
   /** Features that are known experiments and can be referenced in the code. */
   public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
 
-  public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
-      "GerritBackendRequestFeature__remove_revision_etag";
+  public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
 
-  /** Enable storing submit requirements in NoteDb when the change is merged. */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE =
-      "GerritBackendRequestFeature__store_submit_requirements_on_merge";
-
-  /**
-   * Allow legacy {@link com.google.gerrit.entities.SubmitRecord}s to be converted and returned as
-   * submit requirements by the {@link
-   * com.google.gerrit.server.project.SubmitRequirementsEvaluator}.
-   */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS =
-      "GerritBackendRequestFeature__enable_submit_requirements";
-
-  /**
-   * Allow SubmitRequirements to be computed freshly on dashboards irrespective of the value we
-   * retrieved from the change index.
-   */
-  public static final String
-      GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD =
-          "GerritBackendRequestFeature__enable_submit_requirements_backfilling_on_dashboard";
-
-  /**
-   * When set, we compute information from All-Users repository if able, instead of computing it
-   * from the change index.
-   */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
-      "GerritBackendRequestFeature__compute_from_all_users_repository";
+  public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION =
+      "GerritBackendFeature__attach_nonce_to_documentation";
 
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
-      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
+      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
 }
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
index b7ee043..fde4088 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -18,17 +18,17 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Base class for all change events. */
 public abstract class AbstractChangeEvent implements ChangeEvent {
   private final ChangeInfo changeInfo;
   private final AccountInfo who;
-  private final Timestamp when;
+  private final Instant when;
   private final NotifyHandling notify;
 
   protected AbstractChangeEvent(
-      ChangeInfo change, AccountInfo who, Timestamp when, NotifyHandling notify) {
+      ChangeInfo change, AccountInfo who, Instant when, NotifyHandling notify) {
     this.changeInfo = change;
     this.who = who;
     this.when = when;
@@ -46,7 +46,7 @@
   }
 
   @Override
-  public Timestamp getWhen() {
+  public Instant getWhen() {
     return when;
   }
 
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
index 9d4d299..421a5ad 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.RevisionEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Base class for all revision events. */
 public abstract class AbstractRevisionEvent extends AbstractChangeEvent implements RevisionEvent {
@@ -30,7 +30,7 @@
       ChangeInfo change,
       RevisionInfo revision,
       AccountInfo who,
-      Timestamp when,
+      Instant when,
       NotifyHandling notify) {
     super(change, who, when, notify);
     revisionInfo = revision;
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index e31a1b5..8e4d1e2 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a user has been set as assignee on a change. */
 @Singleton
@@ -42,7 +42,7 @@
   }
 
   public void fire(
-      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Timestamp when) {
+      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -63,7 +63,7 @@
   private static class Event extends AbstractChangeEvent implements AssigneeChangedListener.Event {
     private final AccountInfo oldAssignee;
 
-    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.oldAssignee = oldAssignee;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
new file mode 100644
index 0000000..27e0a5e
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.entities.AttentionSetUpdate;
+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.AttentionSetListener;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Helper class to fire an event when an attention set changes. */
+@Singleton
+public class AttentionSetObserver {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<AttentionSetListener> listeners;
+  private final EventUtil util;
+  private final AccountCache accountCache;
+
+  public static final AttentionSetObserver DISABLED =
+      new AttentionSetObserver() {
+        @Override
+        public void fire(
+            ChangeData changeData,
+            AccountState accountState,
+            AttentionSetUpdate update,
+            Instant when) {}
+      };
+
+  @Inject
+  AttentionSetObserver(
+      PluginSetContext<AttentionSetListener> listeners, EventUtil util, AccountCache accountCache) {
+    this.listeners = listeners;
+    this.util = util;
+    this.accountCache = accountCache;
+  }
+
+  /** Constructor only for DISABLED version of the AttentionSetObserver. */
+  private AttentionSetObserver() {
+    this.listeners = null;
+    this.util = null;
+    this.accountCache = null;
+  }
+
+  /**
+   * Notify all listening plugins
+   *
+   * @param changeData is current data of the change
+   * @param accountState is the initiator of the change
+   * @param update is the update that caused the event
+   * @param when is the time of the event
+   */
+  public void fire(
+      ChangeData changeData, AccountState accountState, AttentionSetUpdate update, Instant when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    AccountState target = accountCache.get(update.account()).get();
+
+    HashSet<Integer> added = new HashSet<>();
+    HashSet<Integer> removed = new HashSet<>();
+    switch (update.operation()) {
+      case ADD:
+        added.add(target.account().id().get());
+        break;
+      case REMOVE:
+        removed.add(target.account().id().get());
+        break;
+    }
+
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(changeData), util.accountInfo(accountState), added, removed, when);
+      listeners.runEach(l -> l.onAttentionSetChanged(event));
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Exception while firing AttentionSet changed event");
+    }
+  }
+
+  /** Event to be fired when an attention set changes */
+  public static class Event extends AbstractChangeEvent implements AttentionSetListener.Event {
+    private final Set<Integer> added;
+    private final Set<Integer> removed;
+
+    public Event(
+        ChangeInfo change,
+        AccountInfo editor,
+        Set<Integer> added,
+        Set<Integer> removed,
+        Instant when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.added = added;
+      this.removed = removed;
+    }
+
+    @Override
+    public Set<Integer> usersAdded() {
+      return added;
+    }
+
+    @Override
+    public Set<Integer> usersRemoved() {
+      return removed;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index cbe7c6b..ca1a742 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been abandoned. */
 @Singleton
@@ -53,7 +53,7 @@
       PatchSet ps,
       AccountState abandoner,
       String reason,
-      Timestamp when,
+      Instant when,
       NotifyHandling notifyHandling) {
     if (listeners.isEmpty()) {
       return;
@@ -89,7 +89,7 @@
         RevisionInfo revision,
         AccountInfo abandoner,
         String reason,
-        Timestamp when,
+        Instant when,
         NotifyHandling notifyHandling) {
       super(change, revision, abandoner, when, notifyHandling);
       this.reason = reason;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index 23a4583..acca491 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been deleted. */
 @Singleton
@@ -41,7 +41,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, AccountState deleter, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState deleter, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -55,7 +55,7 @@
 
   /** Event to be fired when a change has been deleted. */
   private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
-    Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo deleter, Instant 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 e4896df..870d850 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been merged. */
 @Singleton
@@ -49,11 +49,7 @@
   }
 
   public void fire(
-      ChangeData changeData,
-      PatchSet ps,
-      AccountState merger,
-      String newRevisionId,
-      Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState merger, String newRevisionId, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -86,7 +82,7 @@
         RevisionInfo revision,
         AccountInfo merger,
         String newRevisionId,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, merger, when, NotifyHandling.ALL);
       this.newRevisionId = newRevisionId;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 8bd222a..c71360b 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been restored. */
 @Singleton
@@ -49,7 +49,7 @@
   }
 
   public void fire(
-      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -83,7 +83,7 @@
         RevisionInfo revision,
         AccountInfo restorer,
         String reason,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, restorer, when, NotifyHandling.ALL);
       this.reason = reason;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index 4a46eb0..1abbebb 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been reverted. */
 @Singleton
@@ -39,7 +39,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, ChangeData revertChangeData, Timestamp when) {
+  public void fire(ChangeData changeData, ChangeData revertChangeData, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -55,7 +55,7 @@
   private static class Event extends AbstractChangeEvent implements ChangeRevertedListener.Event {
     private final ChangeInfo revertChange;
 
-    Event(ChangeInfo change, ChangeInfo revertChange, Timestamp when) {
+    Event(ChangeInfo change, ChangeInfo revertChange, Instant when) {
       super(change, revertChange.owner, when, NotifyHandling.ALL);
       this.revertChange = revertChange;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 20c54cf..79544f2 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 
 /** Helper class to fire an event when a comment or vote has been added to a change. */
@@ -57,7 +57,7 @@
       String comment,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -97,7 +97,7 @@
         String comment,
         Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, author, when, NotifyHandling.ALL);
       this.comment = comment;
       this.approvals = approvals;
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index f0d038a..7c8777f 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -17,6 +17,7 @@
 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.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -36,7 +37,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -98,7 +99,8 @@
     return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
   }
 
-  public AccountInfo accountInfo(AccountState accountState) {
+  @Nullable
+  public AccountInfo accountInfo(@Nullable AccountState accountState) {
     if (accountState == null || accountState.account().id() == null) {
       return null;
     }
@@ -111,7 +113,7 @@
   }
 
   public Map<String, ApprovalInfo> approvals(
-      AccountState accountState, Map<String, Short> approvals, Timestamp ts) {
+      AccountState accountState, Map<String, Short> approvals, Instant ts) {
     Map<String, ApprovalInfo> result = new HashMap<>();
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
       Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
diff --git a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 6ed0a08..814390b 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -17,11 +17,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 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.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -58,17 +62,23 @@
             Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {}
       };
 
-  private final PluginSetContext<GitReferenceUpdatedListener> listeners;
+  private final PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners;
+  private final PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners;
   private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(PluginSetContext<GitReferenceUpdatedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
+  GitReferenceUpdated(
+      PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners,
+      PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners,
+      EventUtil util) {
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
+    this.refUpdatedListeners = refUpdatedListeners;
     this.util = util;
   }
 
   private GitReferenceUpdated() {
-    this.listeners = null;
+    this.batchRefUpdateListeners = null;
+    this.refUpdatedListeners = null;
     this.util = null;
   }
 
@@ -79,20 +89,19 @@
       AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        type,
+        new UpdatedRef(
+            refUpdate.getName(), refUpdate.getOldObjectId(), refUpdate.getNewObjectId(), type),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, RefUpdate refUpdate, AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(
+            refUpdate.getName(),
+            refUpdate.getOldObjectId(),
+            refUpdate.getNewObjectId(),
+            ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
@@ -104,83 +113,80 @@
       AccountState updater) {
     fire(
         project,
-        ref,
-        oldObjectId,
-        newObjectId,
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, ReceiveCommand cmd, AccountState updater) {
     fire(
         project,
-        cmd.getRefName(),
-        cmd.getOldId(),
-        cmd.getNewId(),
-        cmd.getType(),
+        new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.isEmpty() && refUpdatedListeners.isEmpty()) {
       return;
     }
+    Set<GitBatchRefUpdateListener.UpdatedRef> updates = new HashSet<>();
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(
-            project,
-            cmd.getRefName(),
-            cmd.getOldId(),
-            cmd.getNewId(),
-            cmd.getType(),
-            util.accountInfo(updater));
+        updates.add(
+            new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()));
       }
     }
+    fireBatchRefUpdateEvent(project, updates, util.accountInfo(updater));
+    fireRefUpdatedEvents(project, updates, util.accountInfo(updater));
   }
 
-  private void fire(
+  private void fire(Project.NameKey project, UpdatedRef updatedRef, AccountInfo updater) {
+    fireBatchRefUpdateEvent(project, Set.of(updatedRef), updater);
+    fireRefUpdatedEvent(project, updatedRef, updater);
+  }
+
+  private void fireBatchRefUpdateEvent(
       Project.NameKey project,
-      String ref,
-      ObjectId oldObjectId,
-      ObjectId newObjectId,
-      ReceiveCommand.Type type,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
       AccountInfo updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.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);
-    listeners.runEach(l -> l.onGitReferenceUpdated(event));
+    GitBatchRefUpdateEvent event = new GitBatchRefUpdateEvent(project, updatedRefs, updater);
+    batchRefUpdateListeners.runEach(l -> l.onGitBatchRefUpdate(event));
   }
 
-  /** Event to be fired when a Git reference has been updated. */
-  public static class Event implements GitReferenceUpdatedListener.Event {
-    private final String projectName;
-    private final String ref;
-    private final String oldObjectId;
-    private final String newObjectId;
-    private final ReceiveCommand.Type type;
-    private final AccountInfo updater;
-
-    Event(
-        Project.NameKey project,
-        String ref,
-        String oldObjectId,
-        String newObjectId,
-        ReceiveCommand.Type type,
-        AccountInfo updater) {
-      this.projectName = project.get();
-      this.ref = ref;
-      this.oldObjectId = oldObjectId;
-      this.newObjectId = newObjectId;
-      this.type = type;
-      this.updater = updater;
+  private void fireRefUpdatedEvents(
+      Project.NameKey project,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+      AccountInfo updater) {
+    for (GitBatchRefUpdateListener.UpdatedRef updatedRef : updatedRefs) {
+      fireRefUpdatedEvent(project, updatedRef, updater);
     }
+  }
 
-    @Override
-    public String getProjectName() {
-      return projectName;
+  private void fireRefUpdatedEvent(
+      Project.NameKey project,
+      GitBatchRefUpdateListener.UpdatedRef updatedRef,
+      AccountInfo updater) {
+    if (refUpdatedListeners.isEmpty()) {
+      return;
+    }
+    GitReferenceUpdatedEvent event = new GitReferenceUpdatedEvent(project, updatedRef, updater);
+    refUpdatedListeners.runEach(l -> l.onGitReferenceUpdated(event));
+  }
+
+  public static class UpdatedRef implements GitBatchRefUpdateListener.UpdatedRef {
+    private final String ref;
+    private final ObjectId oldObjectId;
+    private final ObjectId newObjectId;
+    private final ReceiveCommand.Type type;
+
+    public UpdatedRef(
+        String ref, ObjectId oldObjectId, ObjectId newObjectId, ReceiveCommand.Type type) {
+      this.ref = ref;
+      this.oldObjectId = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
+      this.newObjectId = newObjectId != null ? newObjectId : ObjectId.zeroId();
+      this.type = type;
     }
 
     @Override
@@ -190,12 +196,12 @@
 
     @Override
     public String getOldObjectId() {
-      return oldObjectId;
+      return oldObjectId.name();
     }
 
     @Override
     public String getNewObjectId() {
-      return newObjectId;
+      return newObjectId.name();
     }
 
     @Override
@@ -214,15 +220,51 @@
     }
 
     @Override
+    public String toString() {
+      return String.format("{%s: %s -> %s}", ref, oldObjectId, newObjectId);
+    }
+  }
+
+  /** Event to be fired when a Git reference has been updated. */
+  public static class GitBatchRefUpdateEvent implements GitBatchRefUpdateListener.Event {
+    private final String projectName;
+    private final Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs;
+    private final AccountInfo updater;
+
+    public GitBatchRefUpdateEvent(
+        Project.NameKey project,
+        Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRefs = updatedRefs;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public Set<GitBatchRefUpdateListener.UpdatedRef> getUpdatedRefs() {
+      return updatedRefs;
+    }
+
+    @Override
+    public Set<String> getRefNames() {
+      return updatedRefs.stream()
+          .map(GitBatchRefUpdateListener.UpdatedRef::getRefName)
+          .collect(Collectors.toSet());
+    }
+
+    @Override
     public AccountInfo getUpdater() {
       return updater;
     }
 
     @Override
     public String toString() {
-      return String.format(
-          "%s[%s,%s: %s -> %s]",
-          getClass().getSimpleName(), projectName, ref, oldObjectId, newObjectId);
+      return String.format("%s[%s,%s]", getClass().getSimpleName(), projectName, updatedRefs);
     }
 
     @Override
@@ -230,4 +272,65 @@
       return NotifyHandling.ALL;
     }
   }
+
+  public static class GitReferenceUpdatedEvent implements GitReferenceUpdatedListener.Event {
+
+    private final String projectName;
+    private final GitBatchRefUpdateListener.UpdatedRef updatedRef;
+    private final AccountInfo updater;
+
+    public GitReferenceUpdatedEvent(
+        Project.NameKey project,
+        GitBatchRefUpdateListener.UpdatedRef updatedRef,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRef = updatedRef;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public NotifyHandling getNotify() {
+      return NotifyHandling.ALL;
+    }
+
+    @Override
+    public String getRefName() {
+      return updatedRef.getRefName();
+    }
+
+    @Override
+    public String getOldObjectId() {
+      return updatedRef.getOldObjectId();
+    }
+
+    @Override
+    public String getNewObjectId() {
+      return updatedRef.getNewObjectId();
+    }
+
+    @Override
+    public boolean isCreate() {
+      return updatedRef.isCreate();
+    }
+
+    @Override
+    public boolean isDelete() {
+      return updatedRef.isDelete();
+    }
+
+    @Override
+    public boolean isNonFastForward() {
+      return updatedRef.isNonFastForward();
+    }
+
+    @Override
+    public AccountInfo getUpdater() {
+      return updater;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 846257c..e7903a2 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.Set;
 
@@ -50,7 +50,7 @@
       ImmutableSortedSet<String> hashtags,
       Set<String> added,
       Set<String> removed,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -82,7 +82,7 @@
         Collection<String> updated,
         Collection<String> added,
         Collection<String> removed,
-        Timestamp when) {
+        Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.updatedHashtags = updated;
       this.addedHashtags = added;
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index d81068c..c6076fd 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the private flag of a change has been toggled. */
 @Singleton
@@ -47,7 +47,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -72,7 +72,7 @@
   private static class Event extends AbstractRevisionEvent
       implements PrivateStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Instant 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 ba73ca1..147e372 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 /** Helper class to fire an event when reviewers have been added to a change. */
@@ -55,7 +55,7 @@
       PatchSet patchSet,
       List<AccountState> reviewers,
       AccountState adder,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty() || reviewers.isEmpty()) {
       return;
     }
@@ -89,7 +89,7 @@
         RevisionInfo revision,
         List<AccountInfo> reviewers,
         AccountInfo adder,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, adder, when, NotifyHandling.ALL);
       this.reviewers = reviewers;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 80037bc..5f9179a 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 
 /** Helper class to fire an event when a reviewer has been deleted from a change. */
@@ -59,7 +59,7 @@
       Map<String, Short> newApprovals,
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -104,7 +104,7 @@
         Map<String, ApprovalInfo> newApprovals,
         Map<String, ApprovalInfo> oldApprovals,
         NotifyHandling notify,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.comment = comment;
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 4c78216..a60d982 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a revision has been created for a change. */
 @Singleton
@@ -47,7 +47,7 @@
             ChangeData changeData,
             PatchSet patchSet,
             AccountState uploader,
-            Timestamp when,
+            Instant when,
             NotifyResolver.Result notify) {}
       };
 
@@ -69,7 +69,7 @@
       ChangeData changeData,
       PatchSet patchSet,
       AccountState uploader,
-      Timestamp when,
+      Instant when,
       NotifyResolver.Result notify) {
     if (listeners.isEmpty()) {
       return;
@@ -102,7 +102,7 @@
         ChangeInfo change,
         RevisionInfo revision,
         AccountInfo uploader,
-        Timestamp when,
+        Instant when,
         NotifyHandling notify) {
       super(change, revision, uploader, when, notify);
     }
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index 08b47f1..008ead5 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the topic of a change has been edited. */
 @Singleton
@@ -41,8 +41,7 @@
     this.util = util;
   }
 
-  public void fire(
-      ChangeData changeData, AccountState account, String oldTopicName, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState account, String oldTopicName, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -59,7 +58,7 @@
   private static class Event extends AbstractChangeEvent implements TopicEditedListener.Event {
     private final String oldTopic;
 
-    Event(ChangeInfo change, AccountInfo editor, String oldTopic, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo editor, String oldTopic, Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.oldTopic = oldTopic;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 244e46c..d127260 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -33,7 +34,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 
 /** Helper class to fire an event when a vote has been deleted from a change. */
@@ -58,8 +59,8 @@
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
       String message,
-      AccountState remover,
-      Timestamp when) {
+      @Nullable AccountState remover,
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -69,8 +70,8 @@
               util.changeInfo(changeData),
               util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
-              util.approvals(remover, approvals, when),
-              util.approvals(remover, oldApprovals, when),
+              util.approvals(reviewer, approvals, when),
+              util.approvals(reviewer, oldApprovals, when),
               notify,
               message,
               util.accountInfo(remover),
@@ -103,7 +104,7 @@
         NotifyHandling notify,
         String message,
         AccountInfo remover,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.approvals = approvals;
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index bfc068d..5e20c45 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the work-in-progress state of a change has been toggled. */
 @Singleton
@@ -42,7 +42,7 @@
       new WorkInProgressStateChanged() {
         @Override
         public void fire(
-            ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {}
+            ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {}
       };
 
   private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
@@ -60,7 +60,7 @@
     this.util = null;
   }
 
-  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -85,7 +85,7 @@
   private static class Event extends AbstractRevisionEvent
       implements WorkInProgressStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Instant when) {
       super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index 9ea628e..df20fbf 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -355,6 +355,11 @@
     }
 
     void processLineToColumn(int to, boolean append) throws IndexOutOfBoundsException {
+      int from = srcPosition.column;
+      if (from > to) {
+        throw new IndexOutOfBoundsException(
+            String.format("The parameter from is greater than to. from: %d, to: %d", from, to));
+      }
       if (to == 0) {
         return;
       }
@@ -366,7 +371,6 @@
           throw new IndexOutOfBoundsException("The processLineToColumn shouldn't add end of line");
         }
       }
-      int from = srcPosition.column;
       int charCount = to - from;
       srcPosition.appendStringWithoutEOLMark(charCount);
       if (append) {
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 242c11b..e27197c 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -30,9 +30,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -78,7 +78,7 @@
 
   private final Provider<IdentifiedUser> currentUser;
   private final GitRepositoryManager repoManager;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PermissionBackend permissionBackend;
   private final NotesBranchUtil.Factory notesBranchUtilFactory;
 
@@ -93,7 +93,7 @@
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
   }
 
   /**
@@ -155,8 +155,7 @@
   }
 
   private PersonIdent createPersonIdent() {
-    Date now = new Date();
-    return currentUser.get().newCommitterIdent(now, tz);
+    return currentUser.get().newCommitterIdent(Instant.now(), zoneId);
   }
 
   private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index d7538ba..79df21a 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -35,7 +38,10 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Extended commit entity with code review specific metadata. */
-public class CodeReviewCommit extends RevCommit {
+public class CodeReviewCommit extends RevCommit implements Serializable {
+
+  private static final long serialVersionUID = 1L;
+
   /**
    * Default ordering when merging multiple topologically-equivalent commits.
    *
@@ -126,7 +132,7 @@
    * 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();
+  private transient Optional<String> statusMessage = Optional.empty();
 
   /** List of files in this commit that contain Git conflict markers. */
   private ImmutableSet<String> filesWithGitConflicts;
@@ -191,4 +197,22 @@
   public void setNotes(ChangeNotes notes) {
     this.notes = notes;
   }
+
+  /** Custom serialization due to {@link #statusMessage} not being Serializable by default. */
+  private void writeObject(ObjectOutputStream oos) throws IOException {
+    oos.defaultWriteObject();
+    if (this.statusMessage.isPresent()) {
+      oos.writeUTF(this.statusMessage.get());
+    }
+  }
+
+  /** Custom deserialization due to {@link #statusMessage} not being Serializable by default. */
+  private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
+    ois.defaultReadObject();
+    String statusMessage = null;
+    if (ois.available() > 0) {
+      statusMessage = ois.readUTF();
+    }
+    this.statusMessage = Optional.ofNullable(statusMessage);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 73378f6..f0b2a78 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -25,10 +25,13 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.CommitInfo;
+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.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -37,12 +40,15 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 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.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;
@@ -53,18 +59,22 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -83,6 +93,7 @@
   private final NotifyResolver notifyResolver;
   private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeMessagesUtil cmUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
   private final MessageIdGenerator messageIdGenerator;
@@ -97,6 +108,7 @@
       NotifyResolver notifyResolver,
       RevertedSender.Factory revertedSenderFactory,
       ChangeMessagesUtil cmUtil,
+      ChangeNotes.Factory changeNotesFactory,
       ChangeReverted changeReverted,
       BatchUpdate.Factory updateFactory,
       MessageIdGenerator messageIdGenerator) {
@@ -108,6 +120,7 @@
     this.notifyResolver = notifyResolver;
     this.revertedSenderFactory = revertedSenderFactory;
     this.cmUtil = cmUtil;
+    this.changeNotesFactory = changeNotesFactory;
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
     this.messageIdGenerator = messageIdGenerator;
@@ -146,7 +159,7 @@
    * @return ObjectId that represents the newly created commit.
    */
   public Change.Id createRevertChange(
-      ChangeNotes notes, CurrentUser user, RevertInput input, Timestamp timestamp)
+      ChangeNotes notes, CurrentUser user, RevertInput input, Instant timestamp)
       throws RestApiException, UpdateException, ConfigInvalidException, IOException {
     String message = Strings.emptyToNull(input.message);
 
@@ -174,7 +187,7 @@
    * @return ObjectId that represents the newly created commit.
    */
   public ObjectId createRevertCommit(
-      String message, ChangeNotes notes, CurrentUser user, Timestamp ts)
+      String message, ChangeNotes notes, CurrentUser user, Instant ts)
       throws RestApiException, IOException {
 
     try (Repository git = repoManager.openRepository(notes.getProjectName());
@@ -188,6 +201,41 @@
   }
 
   /**
+   * Creates a commit with the specified tree ID.
+   *
+   * @param oi ObjectInserter for inserting the newly created commit.
+   * @param authorIdent of the new commit
+   * @param committerIdent of the new commit
+   * @param parentCommit of the new commit. Can be null.
+   * @param commitMessage for the new commit.
+   * @param treeId of the content for the new commit.
+   * @return the newly created commit.
+   * @throws IOException if fails to insert the commit.
+   */
+  public static ObjectId createCommitWithTree(
+      ObjectInserter oi,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      @Nullable RevCommit parentCommit,
+      String commitMessage,
+      ObjectId treeId)
+      throws IOException {
+    logger.atFine().log("Creating commit with tree: %s", treeId.getName());
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(treeId);
+    if (parentCommit != null) {
+      commit.setParentId(parentCommit);
+    }
+    commit.setAuthor(authorIdent);
+    commit.setCommitter(committerIdent);
+    commit.setMessage(commitMessage);
+
+    ObjectId id = oi.insert(commit);
+    oi.flush();
+    return id;
+  }
+
+  /**
    * Creates a revert commit.
    *
    * @param message Commit message for the revert commit.
@@ -206,7 +254,7 @@
       String message,
       ChangeNotes notes,
       CurrentUser user,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       @Nullable ObjectId generatedChangeId)
@@ -220,17 +268,11 @@
 
     PersonIdent committerIdent = serverIdent.get();
     PersonIdent authorIdent =
-        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getTimeZone());
+        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getZoneId());
 
     RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
     revWalk.parseHeaders(parentToCommitToRevert);
 
-    CommitBuilder revertCommitBuilder = new CommitBuilder();
-    revertCommitBuilder.addParentId(commitToRevert);
-    revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
-    revertCommitBuilder.setAuthor(authorIdent);
-    revertCommitBuilder.setCommitter(authorIdent);
-
     Change changeToRevert = notes.getChange();
     String subject = changeToRevert.getSubject();
     if (subject.length() > 63) {
@@ -242,11 +284,11 @@
               ChangeMessages.get().revertChangeDefaultMessage, subject, patch.commitId().name());
     }
     if (generatedChangeId != null) {
-      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, generatedChangeId, true));
+      message = ChangeIdUtil.insertId(message, generatedChangeId, true);
     }
-    ObjectId id = oi.insert(revertCommitBuilder);
-    oi.flush();
-    return id;
+
+    return createCommitWithTree(
+        oi, authorIdent, committerIdent, commitToRevert, message, parentToCommitToRevert.getTree());
   }
 
   private Change.Id createRevertChangeFromCommit(
@@ -255,25 +297,27 @@
       ChangeNotes notes,
       CurrentUser user,
       @Nullable ObjectId generatedChangeId,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       Repository git)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException {
     RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
-    Change changeToRevert = notes.getChange();
     Change.Id changeId = Change.id(seq.nextChangeId());
     if (input.workInProgress) {
-      input.notify = firstNonNull(input.notify, NotifyHandling.OWNER);
+      input.notify = firstNonNull(input.notify, NotifyHandling.NONE);
     }
     NotifyResolver.Result notify =
         notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
 
+    Change changeToRevert = notes.getChange();
     ChangeInserter ins =
         changeInserterFactory
-            .create(changeId, revertCommit, notes.getChange().getDest().branch())
+            .create(changeId, revertCommit, changeToRevert.getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -283,7 +327,7 @@
     reviewers.remove(user.getAccountId());
     Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
     ccs.remove(user.getAccountId());
-    ins.setReviewersAndCcs(reviewers, ccs);
+    ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     ins.setRevertOf(notes.getChangeId());
     ins.setWorkInProgress(input.workInProgress);
 
@@ -291,54 +335,149 @@
       bu.setRepository(git, revWalk, oi);
       bu.setNotify(notify);
       bu.insertChange(ins);
-      bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
-      bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(generatedChangeId));
+      if (!input.workInProgress) {
+        addChangeRevertedNotificationOps(
+            bu, changeToRevert.getId(), changeId, generatedChangeId.name());
+      }
       bu.execute();
     }
     return changeId;
   }
 
-  private class NotifyOp implements BatchUpdateOp {
-    private final Change change;
-    private final ChangeInserter ins;
+  /**
+   * Notify the owners of a change that their change is being reverted.
+   *
+   * @param bu to append the notification actions to.
+   * @param revertedChangeId to be notified.
+   * @param revertingChangeId to notify about.
+   * @param revertingChangeKey to notify about.
+   */
+  public void addChangeRevertedNotificationOps(
+      BatchUpdate bu,
+      Change.Id revertedChangeId,
+      Change.Id revertingChangeId,
+      String revertingChangeKey) {
+    bu.addOp(revertingChangeId, new ChangeRevertedNotifyOp(revertedChangeId, revertingChangeId));
+    bu.addOp(revertedChangeId, new PostRevertedMessageOp(revertingChangeKey));
+  }
 
-    NotifyOp(Change change, ChangeInserter ins) {
-      this.change = change;
-      this.ins = ins;
+  private class ChangeRevertedNotifyOp implements BatchUpdateOp {
+    private final Change.Id revertedChangeId;
+    private final Change.Id revertingChangeId;
+
+    ChangeRevertedNotifyOp(Change.Id revertedChangeId, Change.Id revertingChangeId) {
+      this.revertedChangeId = revertedChangeId;
+      this.revertingChangeId = revertingChangeId;
     }
 
     @Override
     public void postUpdate(PostUpdateContext ctx) throws Exception {
-      changeReverted.fire(
-          ctx.getChangeData(change), ctx.getChangeData(ins.getChange()), ctx.getWhen());
+      ChangeData revertedChange =
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertedChangeId));
+      ChangeData revertingChange =
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
+      changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
       try {
-        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        RevertedSender emailSender =
+            revertedSenderFactory.create(ctx.getProject(), revertedChange.getId());
         emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(change.getId()));
+        emailSender.setNotify(ctx.getNotify(revertedChangeId));
         emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+            messageIdGenerator.fromChangeUpdate(
+                ctx.getRepoView(), revertedChange.currentPatchSet().id()));
         emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
-            "Cannot send email for revert change %s", change.getId());
+            "Cannot send email for revert change %s", revertedChangeId);
       }
     }
   }
 
   private class PostRevertedMessageOp implements BatchUpdateOp {
-    private final ObjectId computedChangeId;
+    private final String revertingChangeKey;
 
-    PostRevertedMessageOp(ObjectId computedChangeId) {
-      this.computedChangeId = computedChangeId;
+    PostRevertedMessageOp(String revertingChangeKey) {
+      this.revertingChangeKey = revertingChangeKey;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx) {
       cmUtil.setChangeMessage(
           ctx,
-          "Created a revert of this change as I" + computedChangeId.name(),
+          "Created a revert of this change as I" + revertingChangeKey,
           ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
+
+  /**
+   * Returns the parent commit for a new commit.
+   *
+   * <p>If {@code baseSha1} is provided, the method verifies it can be used as a base. If {@code
+   * baseSha1} is not provided the tip of the {@code destRef} is returned.
+   *
+   * @param project The name of the project.
+   * @param changeQuery Used for looking up the base commit.
+   * @param revWalk Used for parsing the base commit.
+   * @param destRef The destination branch.
+   * @param baseSha1 The hash of the base commit. Nullable.
+   * @return the base commit. Either the commit matching the provided hash, or the direct parent if
+   *     a hash was not provided.
+   * @throws IOException if the branch reference cannot be parsed.
+   * @throws RestApiException if the base commit cannot be fetched.
+   */
+  public static RevCommit getBaseCommit(
+      String project,
+      InternalChangeQuery changeQuery,
+      RevWalk revWalk,
+      Ref destRef,
+      @Nullable String baseSha1)
+      throws IOException, RestApiException {
+    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(baseSha1)) {
+      return destRefTip;
+    }
+
+    ObjectId baseObjectId;
+    try {
+      baseObjectId = ObjectId.fromString(baseSha1);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException(
+          String.format("Base %s doesn't represent a valid SHA-1", baseSha1), e);
+    }
+
+    RevCommit baseCommit;
+    try {
+      baseCommit = revWalk.parseCommit(baseObjectId);
+    } catch (MissingObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base %s doesn't exist", baseObjectId.name()), e);
+    }
+
+    changeQuery.enforceVisibility(true);
+    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), baseSha1);
+
+    if (changeDatas.isEmpty()) {
+      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
+        // The base commit is a merged commit with no change associated.
+        return baseCommit;
+      }
+      throw new UnprocessableEntityException(
+          String.format("Commit %s does not exist on branch %s", baseSha1, destRef.getName()));
+    } else if (changeDatas.size() != 1) {
+      throw new ResourceConflictException("Multiple changes found for commit " + baseSha1);
+    }
+
+    Change change = changeDatas.get(0).change();
+    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(), baseSha1, ChangeUtil.status(change)));
+  }
 }
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index ddfc115..9046d9d 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -65,6 +65,11 @@
     this.delegate = delegate;
   }
 
+  /** Returns the wrapped {@link Repository} instance. */
+  public Repository delegate() {
+    return delegate;
+  }
+
   @Override
   public void create(boolean bare) throws IOException {
     delegate.create(bare);
@@ -210,12 +215,12 @@
   }
 
   @Override
-  public Set<ObjectId> getAdditionalHaves() {
+  public Set<ObjectId> getAdditionalHaves() throws IOException {
     return delegate.getAdditionalHaves();
   }
 
   @Override
-  public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() {
+  public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() throws IOException {
     return delegate.getAllRefsByPeeledObjectId();
   }
 
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index a2942fe..30330eb 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -85,7 +85,12 @@
       try (Repository repo = repoManager.openRepository(p)) {
         logGcConfiguration(p, repo, aggressive);
         print(writer, "collecting garbage for \"" + p + "\":\n");
-        GarbageCollectCommand gc = Git.wrap(repo).gc();
+        GarbageCollectCommand gc =
+            Git.wrap(
+                    repo instanceof DelegateRepository
+                        ? ((DelegateRepository) repo).delegate()
+                        : repo)
+                .gc();
         gc.setAggressive(aggressive);
         logGcInfo(p, "before:", gc.getStatistics());
         gc.setProgressMonitor(
@@ -131,7 +136,7 @@
       }
       b.append(s);
     }
-    logger.atInfo().log(b.toString());
+    logger.atInfo().log("%s", b);
   }
 
   private static void logGcConfiguration(
@@ -148,7 +153,7 @@
     }
 
     logGcInfo(projectName, "gc config: " + b.toString());
-    logGcInfo(projectName, "pack config: " + (new PackConfig(repo)).toString());
+    logGcInfo(projectName, "pack config: " + new PackConfig(repo).toString());
   }
 
   private static String formatConfigValues(Config config, String section, String subsection) {
@@ -171,7 +176,7 @@
     print(writer, "failed.\n\n");
     StringBuilder b = new StringBuilder();
     b.append("[").append(projectName.get()).append("]");
-    logger.atSevere().withCause(e).log(b.toString());
+    logger.atSevere().withCause(e).log("%s", b);
   }
 
   private static void print(PrintWriter writer, String message) {
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManager.java b/java/com/google/gerrit/server/git/GitRepositoryManager.java
index 8dba3e1..d045baa 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -18,7 +18,7 @@
 import com.google.inject.ImplementedBy;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -74,7 +74,7 @@
       throws RepositoryNotFoundException, RepositoryExistsException, IOException;
 
   /** Returns set of all known projects, sorted by natural NameKey order. */
-  SortedSet<Project.NameKey> list();
+  NavigableSet<Project.NameKey> list();
 
   /**
    * Check if garbage collection can be performed by the repository manager.
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
index 6266925..dfbe663 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager.LocalDiskRepositoryManagerModule;
 import com.google.gerrit.server.git.MultiBaseLocalDiskRepositoryManager.MultiBaseLocalDiskRepositoryManagerModule;
@@ -24,7 +25,9 @@
  * Module to install {@link MultiBaseLocalDiskRepositoryManager} rather than {@link
  * LocalDiskRepositoryManager} if needed.
  */
+@ModuleImpl(name = GitRepositoryManagerModule.MANAGER_MODULE)
 public class GitRepositoryManagerModule extends LifecycleModule {
+  public static final String MANAGER_MODULE = "git-manager";
 
   private final RepositoryConfig repoConfig;
 
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 5bbe5e2..455b221 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.PatchSetUtil;
@@ -258,6 +259,7 @@
     return actual;
   }
 
+  @Nullable
   private ObjectId parseGroup(ObjectId forCommit, String group) {
     try {
       return ObjectId.fromString(group);
diff --git a/java/com/google/gerrit/server/git/HookUtil.java b/java/com/google/gerrit/server/git/HookUtil.java
index fd29c8deb..cafa18e 100644
--- a/java/com/google/gerrit/server/git/HookUtil.java
+++ b/java/com/google/gerrit/server/git/HookUtil.java
@@ -43,12 +43,12 @@
       refs =
           rp.getRepository().getRefDatabase().getRefs().stream()
               .collect(toMap(Ref::getName, r -> r));
+      rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
     } catch (ServiceMayNotContinueException e) {
       throw e;
     } catch (IOException e) {
       throw new ServiceMayNotContinueException(e);
     }
-    rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
     return refs;
   }
 
@@ -70,12 +70,12 @@
       refs =
           up.getRepository().getRefDatabase().getRefs().stream()
               .collect(toMap(Ref::getName, r -> r));
+      up.setAdvertisedRefs(refs);
     } catch (ServiceMayNotContinueException e) {
       throw e;
     } catch (IOException e) {
       throw new ServiceMayNotContinueException(e);
     }
-    up.setAdvertisedRefs(refs);
     return refs;
   }
 
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 8527ff8..57d37fa 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -34,8 +34,10 @@
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.NavigableSet;
 import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
@@ -109,6 +111,7 @@
   }
 
   private final Path basePath;
+  private final Map<Project.NameKey, FileKey> fileKeyByProject = new ConcurrentHashMap<>();
 
   @Inject
   LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
@@ -153,17 +156,23 @@
 
   @Override
   public Repository openRepository(Project.NameKey name) throws RepositoryNotFoundException {
-    return openRepository(getBasePath(name), name);
-  }
+    FileKey cachedLocation = fileKeyByProject.get(name);
+    if (cachedLocation != null) {
+      try {
+        return RepositoryCache.open(cachedLocation);
+      } catch (IOException e) {
+        fileKeyByProject.remove(name, cachedLocation);
+      }
+    }
 
-  private Repository openRepository(Path path, Project.NameKey name)
-      throws RepositoryNotFoundException {
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
-    FileKey loc = FileKey.lenient(path.resolve(name.get()).toFile(), FS.DETECTED);
+    FileKey location = FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
     try {
-      return RepositoryCache.open(loc);
+      Repository repo = RepositoryCache.open(location);
+      fileKeyByProject.put(name, location);
+      return repo;
     } catch (IOException e) {
       throw new RepositoryNotFoundException("Cannot open repository " + name, e);
     }
@@ -254,10 +263,10 @@
   }
 
   @Override
-  public SortedSet<Project.NameKey> list() {
+  public NavigableSet<Project.NameKey> list() {
     ProjectVisitor visitor = new ProjectVisitor(basePath);
     scanProjects(visitor);
-    return Collections.unmodifiableSortedSet(visitor.found);
+    return Collections.unmodifiableNavigableSet(visitor.found);
   }
 
   protected void scanProjects(ProjectVisitor visitor) {
@@ -286,7 +295,7 @@
   }
 
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
-    private final SortedSet<Project.NameKey> found = new TreeSet<>();
+    private final NavigableSet<Project.NameKey> found = new TreeSet<>();
     private Path startFolder;
 
     public ProjectVisitor(Path startFolder) {
@@ -309,7 +318,7 @@
 
     @Override
     public FileVisitResult visitFileFailed(Path file, IOException e) {
-      logger.atWarning().log(e.getMessage());
+      logger.atWarning().log("%s", e.getMessage());
       return FileVisitResult.CONTINUE;
     }
 
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 99cb9b0..6922efb 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -23,6 +23,8 @@
 import static java.util.Comparator.naturalOrder;
 import static java.util.stream.Collectors.joining;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -60,8 +62,6 @@
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.submit.MergeSorter;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -115,6 +115,7 @@
  * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
  * {@code BatchUpdate}.
  */
+@AutoFactory
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -135,12 +136,6 @@
     return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
   }
 
-  public interface Factory {
-    MergeUtil create(ProjectState project);
-
-    MergeUtil create(ProjectState project, boolean useContentMerge);
-  }
-
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final ApprovalsUtil approvalsUtil;
@@ -149,40 +144,38 @@
   private final boolean useRecursiveMerge;
   private final PluggableCommitMessageGenerator commitMessageGenerator;
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted ProjectState project) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project) {
     this(
         serverConfig,
         identifiedUserFactory,
         urlFormatter,
         approvalsUtil,
-        project,
         commitMessageGenerator,
+        project,
         project.is(BooleanProjectConfig.USE_CONTENT_MERGE));
   }
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      @Assisted ProjectState project,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted boolean useContentMerge) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project,
+      boolean useContentMerge) {
     this.identifiedUserFactory = identifiedUserFactory;
     this.urlFormatter = urlFormatter;
     this.approvalsUtil = approvalsUtil;
+    this.commitMessageGenerator = commitMessageGenerator;
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
-    this.commitMessageGenerator = commitMessageGenerator;
   }
 
   public CodeReviewCommit getFirstFastForward(
@@ -307,13 +300,13 @@
     int nameLength = Math.max(oursName.length(), theirsName.length());
     String oursNameFormatted =
         String.format(
-            "%0$-" + nameLength + "s (%s %s)",
+            "%-" + 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)",
+            "%-" + nameLength + "s (%s %s)",
             theirsName,
             abbreviateName(theirs, NAME_ABBREV_LEN),
             theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
@@ -785,8 +778,7 @@
       try {
         failed(rw, mergeTip, n, getCommitMergeStatus(e.getReason()));
       } catch (IOException e2) {
-        logger.atSevere().withCause(e2).log("Failed to set merge failure status for " + n.name());
-        throw new StorageException("Cannot merge " + n.name(), e);
+        throw new StorageException("Cannot merge " + n.name(), e2);
       }
     } catch (IOException e) {
       throw new StorageException("Cannot merge " + n.name(), e);
@@ -1035,6 +1027,7 @@
     }
   }
 
+  @Nullable
   public static CodeReviewCommit findAnyMergedInto(
       CodeReviewRevWalk rw, Iterable<CodeReviewCommit> commits, CodeReviewCommit tip)
       throws IOException {
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 09f08bd..c76c78e 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.DeadlineChecker.getTimeoutFormatter;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.base.Ticker;
 import com.google.common.flogger.FluentLogger;
@@ -36,6 +38,8 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ProgressMonitor;
 
@@ -154,6 +158,64 @@
         return count;
       }
     }
+
+    public int getTotal() {
+      return total;
+    }
+
+    public String getName() {
+      return name;
+    }
+
+    public String getTotalDisplay(int total) {
+      return String.valueOf(total);
+    }
+  }
+
+  /** Handle for a sub-task whose total work can be updated while the task is in progress. */
+  public class VolatileTask extends Task {
+    protected AtomicInteger volatileTotal;
+    protected AtomicBoolean isTotalFinalized = new AtomicBoolean(false);
+
+    public VolatileTask(String subTaskName) {
+      super(subTaskName, UNKNOWN);
+      volatileTotal = new AtomicInteger(UNKNOWN);
+    }
+
+    /**
+     * Update the total work for this sub-task.
+     *
+     * <p>Intended to be called from a worker thread.
+     *
+     * @param workUnits number of work units to be added to existing total work.
+     */
+    public void updateTotal(int workUnits) {
+      if (!isTotalFinalized.get()) {
+        volatileTotal.addAndGet(workUnits);
+      } else {
+        logger.atWarning().log(
+            "Total work has been finalized on sub-task %s and cannot be updated", getName());
+      }
+    }
+
+    /**
+     * Mark the total on this sub-task as unmodifiable.
+     *
+     * <p>Intended to be called from a worker thread.
+     */
+    public void finalizeTotal() {
+      isTotalFinalized.set(true);
+    }
+
+    @Override
+    public int getTotal() {
+      return volatileTotal.get();
+    }
+
+    @Override
+    public String getTotalDisplay(int total) {
+      return super.getTotalDisplay(total) + (isTotalFinalized.get() ? "" : "+");
+    }
   }
 
   public interface Factory {
@@ -189,6 +251,7 @@
    * @param out stream for writing progress messages.
    * @param taskName name of the overall task.
    */
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
@@ -249,6 +312,7 @@
    * calls {@link #end()}, the future has an additional {@code maxInterval} to finish before it is
    * forcefully cancelled and {@link ExecutionException} is thrown.
    *
+   * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
    * @param workerFuture a future that returns when worker threads are finished.
    * @param taskTimeoutTime overall timeout for the task; the future gets a cancellation signal
    *     after this timeout is exceeded; non-positive values indicate no timeout.
@@ -267,6 +331,60 @@
       long cancellationTimeoutTime,
       TimeUnit cancellationTimeoutUnit)
       throws TimeoutException {
+    T t =
+        waitForNonFinalTask(
+            workerFuture,
+            taskTimeoutTime,
+            taskTimeoutUnit,
+            cancellationTimeoutTime,
+            cancellationTimeoutUnit);
+    synchronized (this) {
+      if (!done) {
+        // The worker may not have called end() explicitly, which is likely a
+        // programming error.
+        logger.atWarning().log("MultiProgressMonitor worker did not call end() before returning");
+        end();
+      }
+    }
+    sendDone();
+    return t;
+  }
+
+  /**
+   * Wait for a non-final task managed by a {@link Future}, with no timeout.
+   *
+   * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
+   */
+  public <T> T waitForNonFinalTask(Future<T> workerFuture) {
+    try {
+      return waitForNonFinalTask(workerFuture, 0, null, 0, null);
+    } catch (TimeoutException e) {
+      throw new IllegalStateException("timout exception without setting a timeout", e);
+    }
+  }
+
+  /**
+   * Wait for a task managed by a {@link Future}. This call does not expect the worker thread to
+   * call {@link #end()}. It is intended to be used to track a non-final task.
+   *
+   * @param workerFuture a future that returns when worker threads are finished.
+   * @param taskTimeoutTime overall timeout for the task; the future is forcefully cancelled if the
+   *     task exceeds the timeout. Non-positive values indicate no timeout.
+   * @param taskTimeoutUnit unit for overall task timeout.
+   * @param cancellationTimeoutTime timeout for the task to react to the cancellation signal; if the
+   *     task doesn't terminate within this time it is forcefully cancelled; non-positive values
+   *     indicate no timeout.
+   * @param cancellationTimeoutUnit unit for the cancellation timeout.
+   * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was
+   *     cancelled, or timed out waiting for a worker to call {@link #end()}.
+   */
+  public <T> T waitForNonFinalTask(
+      Future<T> workerFuture,
+      long taskTimeoutTime,
+      TimeUnit taskTimeoutUnit,
+      long cancellationTimeoutTime,
+      TimeUnit cancellationTimeoutUnit)
+      throws TimeoutException {
     long overallStart = ticker.read();
     long cancellationNanos =
         cancellationTimeoutTime > 0
@@ -282,7 +400,7 @@
 
     synchronized (this) {
       long left = maxIntervalNanos;
-      while (!done) {
+      while (!workerFuture.isDone() && !done) {
         long start = ticker.read();
         try {
           // Conditions below gives better granularity for timeouts.
@@ -349,19 +467,11 @@
           left = maxIntervalNanos;
         }
         sendUpdate();
-        if (!done && workerFuture.isDone()) {
-          // The worker may not have called end() explicitly, which is likely a
-          // programming error.
-          logger.atWarning().log(
-              "MultiProgressMonitor worker did not call end() before returning (task=%s(%s))",
-              taskKind, taskName);
-          end();
-        }
       }
       if (deadlineExceeded && !forcefulTermination && taskKind == TaskKind.RECEIVE_COMMITS) {
         cancellationMetrics.countGracefulReceiveTimeout();
       }
-      sendDone();
+      wakeUp();
     }
 
     // The loop exits as soon as the worker calls end(), but we give it another
@@ -398,6 +508,18 @@
   }
 
   /**
+   * Begin a sub-task whose total work can be updated.
+   *
+   * @param subTask sub-task name.
+   * @return sub-task handle.
+   */
+  public VolatileTask beginVolatileSubTask(String subTask) {
+    VolatileTask task = new VolatileTask(subTask);
+    tasks.add(task);
+    return task;
+  }
+
+  /**
    * End the overall task.
    *
    * <p>Must be called from a worker thread.
@@ -440,6 +562,7 @@
       boolean first = true;
       for (Task t : tasks) {
         int count = t.getCount();
+        int total = t.getTotal();
         if (count == 0) {
           continue;
         }
@@ -454,10 +577,11 @@
         if (!Strings.isNullOrEmpty(t.name)) {
           s.append(t.name).append(": ");
         }
-        if (t.total == UNKNOWN) {
+        if (total == UNKNOWN) {
           s.append(count);
         } else {
-          s.append(String.format("%d%% (%d/%d)", count * 100 / t.total, count, t.total));
+          s.append(
+              String.format("%d%% (%d/%s)", count * 100 / total, count, t.getTotalDisplay(total)));
         }
       }
     }
@@ -470,9 +594,12 @@
   }
 
   private void send(StringBuilder s) {
+    String progress = s.toString();
+    logger.atInfo().atMostEvery(1, MINUTES).log(
+        "%s", CharMatcher.javaIsoControl().removeFrom(progress));
     if (!clientDisconnected) {
       try {
-        out.write(Constants.encode(s.toString()));
+        out.write(Constants.encode(progress));
         out.flush();
       } catch (IOException e) {
         logger.atWarning().withCause(e).log(
diff --git a/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index b7db542..9b5a674 100644
--- a/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -89,7 +89,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
index 99a66f8..fb34753 100644
--- a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.base.Preconditions;
@@ -83,6 +82,7 @@
     throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
   }
 
+  @Nullable
   @Override
   public Ref exactRef(String name) throws IOException {
     Ref ref = getDelegate().getRefDatabase().exactRef(name);
@@ -108,20 +108,11 @@
     return Iterables.getOnlyElement(result);
   }
 
+  // WARNING: This method is deprecated in JGit's RefDatabase and it will be removed on master.
+  // Do not add any logic here but rather enrich the getRefsByPrefix method below.
   @Override
   public Map<String, Ref> getRefs(String prefix) throws IOException {
-    List<Ref> refs = getDelegate().getRefDatabase().getRefsByPrefix(prefix);
-    if (refs.isEmpty()) {
-      return Collections.emptyMap();
-    }
-
-    Collection<Ref> result;
-    try {
-      result = forProject.filter(refs, getDelegate(), RefFilterOptions.defaults());
-    } catch (PermissionBackendException e) {
-      throw new IOException("", e);
-    }
-    return buildPrefixRefMap(prefix, result);
+    return buildPrefixRefMap(prefix, getRefsByPrefix(prefix));
   }
 
   private Map<String, Ref> buildPrefixRefMap(String prefix, Collection<Ref> refs) {
@@ -138,26 +129,18 @@
 
   @Override
   public List<Ref> getRefsByPrefix(String prefix) throws IOException {
-    Map<String, Ref> coarseRefs;
-    int lastSlash = prefix.lastIndexOf('/');
-    if (lastSlash == -1) {
-      coarseRefs = getRefs(ALL);
-    } else {
-      coarseRefs = getRefs(prefix.substring(0, lastSlash + 1));
+    List<Ref> refs = getDelegate().getRefDatabase().getRefsByPrefix(prefix);
+    if (refs.isEmpty()) {
+      return Collections.emptyList();
     }
 
-    List<Ref> result;
-    if (lastSlash + 1 == prefix.length()) {
-      result = coarseRefs.values().stream().collect(toList());
-    } else {
-      String p = prefix.substring(lastSlash + 1);
-      result =
-          coarseRefs.entrySet().stream()
-              .filter(e -> e.getKey().startsWith(p))
-              .map(e -> e.getValue())
-              .collect(toList());
+    Collection<Ref> result;
+    try {
+      result = forProject.filter(refs, getDelegate(), RefFilterOptions.defaults());
+    } catch (PermissionBackendException e) {
+      throw new IOException("", e);
     }
-    return Collections.unmodifiableList(result);
+    return result.stream().collect(Collectors.toList());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 3910393..90eadf3 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -138,13 +138,13 @@
 
   static class Loader extends CacheLoader<PureRevertKeyProto, Boolean> {
     private final GitRepositoryManager repoManager;
-    private final MergeUtil.Factory mergeUtilFactory;
+    private final MergeUtilFactory mergeUtilFactory;
     private final ProjectCache projectCache;
 
     @Inject
     Loader(
         GitRepositoryManager repoManager,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         ProjectCache projectCache) {
       this.repoManager = repoManager;
       this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/git/RefCache.java b/java/com/google/gerrit/server/git/RefCache.java
index 5a5cae9..2dee427 100644
--- a/java/com/google/gerrit/server/git/RefCache.java
+++ b/java/com/google/gerrit/server/git/RefCache.java
@@ -37,4 +37,7 @@
    *     present with a value of {@link ObjectId#zeroId()}.
    */
   Optional<ObjectId> get(String refName) throws IOException;
+
+  /** Closes this cache, releasing the references to any underlying resources. */
+  void close();
 }
diff --git a/java/com/google/gerrit/server/git/RepoRefCache.java b/java/com/google/gerrit/server/git/RepoRefCache.java
index c69f9a6..d2b3c32 100644
--- a/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -28,8 +28,11 @@
 public class RepoRefCache implements RefCache {
   private final RefDatabase refdb;
   private final Map<String, Optional<ObjectId>> ids;
+  private final Repository repo;
 
   public RepoRefCache(Repository repo) {
+    repo.incrementOpen();
+    this.repo = repo;
     this.refdb = repo.getRefDatabase();
     this.ids = new HashMap<>();
   }
@@ -50,4 +53,9 @@
   public Map<String, Optional<ObjectId>> getCachedRefs() {
     return Collections.unmodifiableMap(ids);
   }
+
+  @Override
+  public void close() {
+    repo.close();
+  }
 }
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index ff5bcc2..cfeec70 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -41,10 +41,11 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import com.google.inject.util.Providers;
-import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
 
 /**
  * Cache based on an index query of the most recent changes. The number of cached items depends on
@@ -116,22 +117,23 @@
    * Additional stored fields are not loaded from the index.
    *
    * @param project project to read.
-   * @return list of known changes; empty if no changes.
+   * @return stream of known changes; empty if no changes.
    */
-  public List<ChangeData> getChangeData(Project.NameKey project) {
+  public Stream<ChangeData> getChangeData(Project.NameKey project) {
+    List<CachedChange> cached;
     try {
-      List<CachedChange> cached = cache.get(project);
-      List<ChangeData> cds = new ArrayList<>(cached.size());
-      for (CachedChange cc : cached) {
-        ChangeData cd = changeDataFactory.create(cc.change());
-        cd.setReviewers(cc.reviewers());
-        cds.add(cd);
-      }
-      return Collections.unmodifiableList(cds);
+      cached = cache.get(project);
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot fetch changes for %s", project);
-      return Collections.emptyList();
+      return Stream.empty();
     }
+    return cached.stream()
+        .map(
+            cc -> {
+              ChangeData cd = changeDataFactory.create(cc.change());
+              cd.setReviewers(cc.reviewers());
+              return cd;
+            });
   }
 
   @Override
@@ -160,14 +162,20 @@
         List<ChangeData> cds =
             queryProvider
                 .get()
-                .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER)
+                .setRequestedFields(ChangeField.CHANGE_SPEC, ChangeField.REVIEWER_SPEC)
                 .byProject(key);
-        List<CachedChange> result = new ArrayList<>(cds.size());
+        Map<Change.Id, CachedChange> result = new HashMap<>(cds.size());
         for (ChangeData cd : cds) {
-          result.add(
+          if (result.containsKey(cd.getId())) {
+            logger.atWarning().log(
+                "Duplicate changes returned from change query by project %s: %s, %s",
+                key, cd.change(), result.get(cd.getId()).change());
+          }
+          result.put(
+              cd.getId(),
               new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
-        return Collections.unmodifiableList(result);
+        return List.copyOf(result.values());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 8b59474..e8b7c62 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -18,8 +18,10 @@
 
 import com.google.common.base.CaseFormat;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -27,14 +29,14 @@
 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.plugincontext.PluginMapContext;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.lang.Thread.UncaughtExceptionHandler;
 import java.lang.reflect.Field;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -50,8 +52,8 @@
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.lib.Config;
 
 /** Delayed execution of tasks using a background thread pool. */
@@ -59,6 +61,30 @@
 public class WorkQueue {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /**
+   * To register a TaskListener, which will be called directly before Tasks run, and directly after
+   * they complete, bind the TaskListener like this:
+   *
+   * <p><code>
+   *   bind(TaskListener.class)
+   *       .annotatedWith(Exports.named("MyListener"))
+   *       .to(MyListener.class);
+   * </code>
+   */
+  public interface TaskListener {
+    public static class NoOp implements TaskListener {
+      @Override
+      public void onStart(Task<?> task) {}
+
+      @Override
+      public void onStop(Task<?> task) {}
+    }
+
+    void onStart(Task<?> task);
+
+    void onStop(Task<?> task);
+  }
+
   public static class Lifecycle implements LifecycleListener {
     private final WorkQueue workQueue;
 
@@ -79,31 +105,42 @@
   public static class WorkQueueModule extends LifecycleModule {
     @Override
     protected void configure() {
+      DynamicMap.mapOf(binder(), WorkQueue.TaskListener.class);
       bind(WorkQueue.class);
       listener().to(Lifecycle.class);
     }
   }
 
-  private static final UncaughtExceptionHandler LOG_UNCAUGHT_EXCEPTION =
-      (t, e) ->
-          logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
-
   private final ScheduledExecutorService defaultQueue;
   private final IdGenerator idGenerator;
   private final MetricMaker metrics;
   private final CopyOnWriteArrayList<Executor> queues;
+  private final PluginMapContext<TaskListener> listeners;
 
   @Inject
-  WorkQueue(IdGenerator idGenerator, @GerritServerConfig Config cfg, MetricMaker metrics) {
-    this(idGenerator, Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2), metrics);
+  WorkQueue(
+      IdGenerator idGenerator,
+      @GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
+    this(
+        idGenerator,
+        Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2),
+        metrics,
+        listeners);
   }
 
   /** Constructor to allow binding the WorkQueue more explicitly in a vhost setup. */
-  public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize, MetricMaker metrics) {
+  public WorkQueue(
+      IdGenerator idGenerator,
+      int defaultThreadPoolSize,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
     this.idGenerator = idGenerator;
     this.metrics = metrics;
     this.queues = new CopyOnWriteArrayList<>();
     this.defaultQueue = createQueue(defaultThreadPoolSize, "WorkQueue", true);
+    this.listeners = listeners;
   }
 
   /** Get the default work queue, for miscellaneous tasks. */
@@ -152,6 +189,7 @@
    * @param threadPriority thread priority.
    * @param withMetrics whether to create metrics.
    */
+  @SuppressWarnings("ThreadPriorityCheck")
   public ScheduledThreadPoolExecutor createQueue(
       int poolsize, String queueName, int threadPriority, boolean withMetrics) {
     Executor executor = new Executor(poolsize, queueName);
@@ -204,6 +242,7 @@
   }
 
   /** Locate a task by its unique id, null if no task matches. */
+  @Nullable
   public Task<?> getTask(int id) {
     Task<?> result = null;
     for (Executor e : queues) {
@@ -219,6 +258,7 @@
     return result;
   }
 
+  @Nullable
   public ScheduledThreadPoolExecutor getExecutor(String queueName) {
     for (Executor e : queues) {
       if (e.queueName.equals(queueName)) {
@@ -259,7 +299,7 @@
             public Thread newThread(Runnable task) {
               final Thread t = parent.newThread(task);
               t.setName(queueName + "-" + tid.getAndIncrement());
-              t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
+              t.setUncaughtExceptionHandler(WorkQueue::logUncaughtException);
               return t;
             }
           });
@@ -442,6 +482,18 @@
     Collection<Task<?>> getTasks() {
       return all.values();
     }
+
+    public void onStart(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStart(task));
+    }
+
+    public void onStop(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStop(task));
+    }
+  }
+
+  private static void logUncaughtException(Thread t, Throwable e) {
+    logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
   }
 
   /**
@@ -474,18 +526,23 @@
      * <ol>
      *   <li>{@link #SLEEPING}: if scheduled with a non-zero delay.
      *   <li>{@link #READY}: waiting for an available worker thread.
+     *   <li>{@link #STARTING}: onStart() actively executing on a worker thread.
      *   <li>{@link #RUNNING}: actively executing on a worker thread.
+     *   <li>{@link #STOPPING}: onStop() actively executing on a worker thread.
      *   <li>{@link #DONE}: finished executing, if not periodic.
      * </ol>
      */
     public enum State {
       // Ordered like this so ordinal matches the order we would
       // prefer to see tasks sorted in: done before running,
-      // running before ready, ready before sleeping.
+      // stopping before running, running before starting,
+      // starting before ready, ready before sleeping.
       //
       DONE,
       CANCELLED,
+      STOPPING,
       RUNNING,
+      STARTING,
       READY,
       SLEEPING,
       OTHER
@@ -495,16 +552,17 @@
     private final RunnableScheduledFuture<V> task;
     private final Executor executor;
     private final int taskId;
-    private final AtomicBoolean running;
-    private final Date startTime;
+    private final Instant startTime;
+
+    // runningState is non-null when listener or task code is running in an executor thread
+    private final AtomicReference<State> runningState = new AtomicReference<>();
 
     Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
       this.runnable = runnable;
       this.task = task;
       this.executor = executor;
       this.taskId = taskId;
-      this.running = new AtomicBoolean();
-      this.startTime = new Date();
+      this.startTime = Instant.now();
     }
 
     public int getTaskId() {
@@ -514,10 +572,13 @@
     public State getState() {
       if (isCancelled()) {
         return State.CANCELLED;
+      }
+
+      State r = runningState.get();
+      if (r != null) {
+        return r;
       } else if (isDone() && !isPeriodic()) {
         return State.DONE;
-      } else if (running.get()) {
-        return State.RUNNING;
       }
 
       final long delay = getDelay(TimeUnit.MILLISECONDS);
@@ -527,7 +588,7 @@
       return State.SLEEPING;
     }
 
-    public Date getStartTime() {
+    public Instant getStartTime() {
       return startTime;
     }
 
@@ -538,14 +599,14 @@
     @Override
     public boolean cancel(boolean mayInterruptIfRunning) {
       if (task.cancel(mayInterruptIfRunning)) {
-        // Tiny abuse of running: if the task needs to know it was
-        // canceled (to clean up resources) and it hasn't started
+        // Tiny abuse of runningState: if the task needs to know it
+        // was canceled (to clean up resources) and it hasn't started
         // yet the task's run method won't execute. So we tag it
         // as running and allow it to clean up. This ensures we do
         // not invoke cancel twice.
         //
         if (runnable instanceof CancelableRunnable) {
-          if (running.compareAndSet(false, true)) {
+          if (runningState.compareAndSet(null, State.RUNNING)) {
             ((CancelableRunnable) runnable).cancel();
           } else if (runnable instanceof CanceledWhileRunning) {
             ((CanceledWhileRunning) runnable).setCanceledWhileRunning();
@@ -605,16 +666,21 @@
 
     @Override
     public void run() {
-      if (running.compareAndSet(false, true)) {
+      if (runningState.compareAndSet(null, State.STARTING)) {
         String oldThreadName = Thread.currentThread().getName();
         try {
+          executor.onStart(this);
+          runningState.set(State.RUNNING);
           Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
           task.run();
         } finally {
           Thread.currentThread().setName(oldThreadName);
+          runningState.set(State.STOPPING);
+          executor.onStop(this);
           if (isPeriodic()) {
-            running.set(false);
+            runningState.set(null);
           } else {
+            runningState.set(State.DONE);
             executor.remove(this);
           }
         }
diff --git a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
index 27d5da9..befdb58 100644
--- a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
+++ b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
@@ -135,7 +135,7 @@
 
     private PersonIdent createPersonIdent(IdentifiedUser user) {
       PersonIdent serverIdent = serverIdentProvider.get();
-      return user.newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+      return user.newCommitterIdent(serverIdent);
     }
   }
 
@@ -215,11 +215,7 @@
 
   public void setAuthor(IdentifiedUser author) {
     this.author = author;
-    getCommitBuilder()
-        .setAuthor(
-            author.newCommitterIdent(
-                getCommitBuilder().getCommitter().getWhen(),
-                getCommitBuilder().getCommitter().getTimeZone()));
+    getCommitBuilder().setAuthor(author.newCommitterIdent(getCommitBuilder().getCommitter()));
   }
 
   public void setAllowEmpty(boolean allowEmpty) {
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 80570a5..5f76b39 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -84,6 +85,7 @@
     return map;
   }
 
+  @Nullable
   protected static String asText(String left, String right, Map<String, String> entries) {
     if (entries.isEmpty()) {
       return null;
@@ -96,6 +98,7 @@
     return asText(left, right, rows);
   }
 
+  @Nullable
   protected static String asText(String left, String right, List<Row> rows) {
     if (rows.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index a42ab8f..61bd8a8 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -210,6 +210,27 @@
   }
 
   /**
+   * Update this metadata branch, recording a new commit on its reference. This method mutates its
+   * receiver.
+   *
+   * @param update helper information to define the update that will occur.
+   * @param objInserter Shared object inserter.
+   * @param objReader Shared object reader.
+   * @param revWalk Shared rev walk.
+   * @return the commit that was created
+   * @throws IOException if there is a storage problem and the update cannot be executed as
+   *     requested or if it failed because of a concurrent update to the same reference
+   */
+  public RevCommit commit(
+      MetaDataUpdate update, ObjectInserter objInserter, ObjectReader objReader, RevWalk revWalk)
+      throws IOException {
+    try (BatchMetaDataUpdate batch = openUpdate(update, objInserter, objReader, revWalk)) {
+      batch.write(update.getCommitBuilder());
+      return batch.commit();
+    }
+  }
+
+  /**
    * Creates a new commit and a new ref based on this commit. This method mutates its receiver.
    *
    * @param update helper information to define the update that will occur.
@@ -256,11 +277,39 @@
    * @throws IOException if the update failed.
    */
   public BatchMetaDataUpdate openUpdate(MetaDataUpdate update) throws IOException {
+    return openUpdate(update, null, null, null);
+  }
+
+  /**
+   * Open a batch of updates to the same metadata ref.
+   *
+   * <p>This allows making multiple commits to a single metadata ref, at the end of which is a
+   * single ref update. For batching together updates to multiple refs (each consisting of one or
+   * more commits against their respective refs), create the {@link MetaDataUpdate} with a {@link
+   * BatchRefUpdate}.
+   *
+   * <p>A ref update produced by this {@link BatchMetaDataUpdate} is only committed if there is no
+   * associated {@link BatchRefUpdate}. As a result, the configured ref updated event is not fired
+   * if there is an associated batch.
+   *
+   * <p>If object inserter, reader and revwalk are provided, then the updates are not flushed,
+   * allowing callers the flexibility to flush only once after several updates.
+   *
+   * @param update helper info about the update.
+   * @param objInserter Shared object inserter.
+   * @param objReader Shared object reader.
+   * @param revWalk Shared rev walk.
+   * @throws IOException if the update failed.
+   */
+  public BatchMetaDataUpdate openUpdate(
+      MetaDataUpdate update, ObjectInserter objInserter, ObjectReader objReader, RevWalk revWalk)
+      throws IOException {
     final Repository db = update.getRepository();
 
-    inserter = db.newObjectInserter();
-    reader = inserter.newReader();
-    final RevWalk rw = new RevWalk(reader);
+    inserter = objInserter == null ? db.newObjectInserter() : objInserter;
+    reader = objReader == null ? inserter.newReader() : objReader;
+    final RevWalk rw = revWalk == null ? new RevWalk(reader) : revWalk;
+
     final RevTree tree = revision != null ? rw.parseTree(revision) : null;
     newTree = readTree(tree);
     return new BatchMetaDataUpdate() {
@@ -372,13 +421,16 @@
       public void close() {
         newTree = null;
 
-        rw.close();
-        if (inserter != null) {
+        if (revWalk == null) {
+          rw.close();
+        }
+
+        if (objInserter == null && inserter != null) {
           inserter.close();
           inserter = null;
         }
 
-        if (reader != null) {
+        if (objReader == null && reader != null) {
           reader.close();
           reader = null;
         }
@@ -389,7 +441,9 @@
         BatchRefUpdate bru = update.getBatch();
         if (bru != null) {
           bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
-          inserter.flush();
+          if (objInserter == null) {
+            inserter.flush();
+          }
           revision = rw.parseCommit(newId);
           return revision;
         }
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 85d7db0..08849348 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -36,6 +35,7 @@
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PublishCommentsOp;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -290,10 +290,7 @@
     receivePack.setPreReceiveHook(asHook());
     receivePack.setPostReceiveHook(lazyPostReceive.create(user, projectName));
 
-    try {
-      projectState.checkStatePermitsRead();
-      this.perm.check(ProjectPermission.READ);
-    } catch (AuthException | ResourceConflictException e) {
+    if (!projectState.statePermitsRead() || !this.perm.test(ProjectPermission.READ)) {
       receivePack.setCheckReferencedObjectsAreReachable(
           receiveConfig.checkReferencedObjectsAreReachable);
     }
@@ -325,9 +322,7 @@
 
   /** Determine if the user can upload commits. */
   public Capable canUpload() throws IOException, PermissionBackendException {
-    try {
-      perm.check(ProjectPermission.PUSH_AT_LEAST_ONE_REF);
-    } catch (AuthException e) {
+    if (!perm.test(ProjectPermission.PUSH_AT_LEAST_ONE_REF)) {
       return new Capable("Upload denied for project '" + projectState.getName() + "'");
     }
 
@@ -387,7 +382,7 @@
         () -> {
           String oldName = Thread.currentThread().getName();
           Thread.currentThread().setName(oldName + "-for-" + currentThreadName);
-          try {
+          try (PerThreadCache threadLocalCache = PerThreadCache.create()) {
             return receiveCommits.processCommands(commands, monitor);
           } finally {
             Thread.currentThread().setName(oldName);
diff --git a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
index 72483af..12666f9 100644
--- a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
+++ b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
@@ -80,13 +80,13 @@
         r =
             rp.getRepository().getRefDatabase().getRefs().stream()
                 .collect(toMap(Ref::getName, x -> x));
+        rp.setAdvertisedRefs(r, history(r.values(), rp));
       } catch (ServiceMayNotContinueException e) {
         throw e;
       } catch (IOException e) {
         throw new ServiceMayNotContinueException(e);
       }
     }
-    rp.setAdvertisedRefs(r, history(r.values(), rp));
   }
 
   private Set<ObjectId> history(Collection<Ref> refs, ReceivePack rp) {
diff --git a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
index a19dbac..a562659 100644
--- a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
+++ b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
@@ -72,7 +72,7 @@
             String.format(
                 "%s request failed for project %s with [%s]",
                 REPOSITORY_SIZE_GROUP, project, a.errorMessage());
-        logger.atWarning().log(msg);
+        logger.atWarning().log("%s", msg);
         throw new RuntimeException(msg);
       }
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index f4c7a92..093ca78 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -439,6 +439,7 @@
   private MessageSender messageSender;
   private ReceiveCommitsResult.Builder result;
   private ImmutableMap<String, String> loggingTags;
+  private ImmutableList<String> transitionalPluginOptions;
 
   /** This object is for single use only. */
   private boolean used;
@@ -590,6 +591,8 @@
         useRefCache
             ? ReceivePackRefCache.withAdvertisedRefs(() -> allRefsWatcher.getAllRefs())
             : ReceivePackRefCache.noCache(receivePack.getRepository().getRefDatabase());
+    this.transitionalPluginOptions =
+        ImmutableList.copyOf(config.getStringList("plugins", null, "transitionalPushOptions"));
   }
 
   void init() {
@@ -781,7 +784,7 @@
     Task newProgress = progress.beginSubTask("new", UNKNOWN);
     Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
 
-    List<CreateRequest> newChanges = Collections.emptyList();
+    ImmutableList<CreateRequest> newChanges = ImmutableList.of();
     try {
       if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
         try {
@@ -840,7 +843,7 @@
       Map<BranchNameKey, ReceiveCommand> branches;
       try (BatchUpdate bu =
               batchUpdateFactory.create(
-                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.now());
           ObjectInserter ins = repo.newObjectInserter();
           ObjectReader reader = ins.newReader();
           RevWalk rw = new RevWalk(reader);
@@ -862,7 +865,7 @@
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
-        orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
+        orm.setContext(TimeUtil.now(), user, NotifyResolver.Result.none());
         submissionExecutor.afterExecutions(orm);
 
         branches = bu.getSuccessfullyUpdatedBranches(false);
@@ -1007,9 +1010,11 @@
             .setIsWorkInProgress(wip)
             .build();
     addMessage(changeFormatter.changeUpdated(input));
+    u.getOutdatedApprovalsMessage().map(msg -> "\n" + msg + "\n").ifPresent(this::addMessage);
   }
 
-  private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
+  private void insertChangesAndPatchSets(
+      ImmutableList<CreateRequest> newChanges, Task replaceProgress) {
     try (TraceTimer traceTimer =
         newTimer(
             "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -1025,7 +1030,7 @@
 
       try (BatchUpdate bu =
               batchUpdateFactory.create(
-                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.now());
           ObjectInserter ins = repo.newObjectInserter();
           ObjectReader reader = ins.newReader();
           RevWalk rw = new RevWalk(reader)) {
@@ -1252,9 +1257,7 @@
                   + NoteDbPushOption.ALLOW.value());
           return;
         }
-        try {
-          permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
-        } catch (AuthException e) {
+        if (!permissionBackend.user(user).test(GlobalPermission.ACCESS_DATABASE)) {
           reject(cmd, "NoteDb update requires access database permission");
           return;
         }
@@ -1296,9 +1299,7 @@
   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) {
+      if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
         reject(
             cmd,
             String.format(
@@ -1336,20 +1337,16 @@
             } else {
               if (!oldParent.equals(newParent)) {
                 if (allowProjectOwnersToChangeParent) {
-                  try {
-                    permissionBackend
-                        .user(user)
-                        .project(project.getNameKey())
-                        .check(ProjectPermission.WRITE_CONFIG);
-                  } catch (AuthException e) {
+                  if (!permissionBackend
+                      .user(user)
+                      .project(project.getNameKey())
+                      .test(ProjectPermission.WRITE_CONFIG)) {
                     reject(
                         cmd, "invalid project configuration: only project owners can set parent");
                     return;
                   }
                 } else {
-                  try {
-                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-                  } catch (AuthException e) {
+                  if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
                     reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
                     return;
                   }
@@ -1430,6 +1427,12 @@
   private void parseCreate(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
     try (TraceTimer traceTimer = newTimer("parseCreate")) {
+      if (repo.resolve(cmd.getRefName()) != null) {
+        reject(
+            cmd,
+            String.format("Cannot create ref '%s' because it already exists.", cmd.getRefName()));
+        return;
+      }
       RevObject obj;
       try {
         obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
@@ -1450,7 +1453,7 @@
         // 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);
+            Providers.of(user), receivePack.getRepository(), branch, obj, /* forPush= */ true);
       } catch (AuthException denied) {
         rejectProhibited(cmd, denied);
         return;
@@ -2132,6 +2135,9 @@
   }
 
   private boolean isPluginPushOption(String pushOptionName) {
+    if (transitionalPluginOptions.contains(pushOptionName)) {
+      return true;
+    }
     return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
         .anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName()));
   }
@@ -2232,7 +2238,8 @@
                             comment.message.length()))
                 .collect(toImmutableList());
         CommentValidationContext ctx =
-            CommentValidationContext.create(change.getChangeId(), change.getProject().get());
+            CommentValidationContext.create(
+                change.getChangeId(), change.getProject().get(), change.getDest().branch());
         ImmutableList<CommentValidationFailure> commentValidationFailures =
             PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation);
         magicBranch.setWithholdComments(!commentValidationFailures.isEmpty());
@@ -2248,7 +2255,7 @@
     }
   }
 
-  private void warnAboutMissingChangeId(List<CreateRequest> newChanges) {
+  private void warnAboutMissingChangeId(ImmutableList<CreateRequest> newChanges) {
     for (CreateRequest create : newChanges) {
       try {
         receivePack.getRevWalk().parseBody(create.commit);
@@ -2265,7 +2272,7 @@
     }
   }
 
-  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
+  private ImmutableList<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
       throws IOException {
     try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) {
       logger.atFine().log("Finding new and replaced changes");
@@ -2280,7 +2287,7 @@
       try {
         RevCommit start = setUpWalkForSelectingChanges();
         if (start == null) {
-          return Collections.emptyList();
+          return ImmutableList.of();
         }
 
         LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
@@ -2358,7 +2365,7 @@
             reject(
                 magicBranch.cmd,
                 "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (commitAlreadyTracked) {
@@ -2391,7 +2398,7 @@
           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();
+            return ImmutableList.of();
           }
 
           // Don't allow merges to be uploaded in commit chain via all-not-in-target
@@ -2428,7 +2435,7 @@
           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();
+            return ImmutableList.of();
           }
 
           List<ChangeData> changes = p.destChanges;
@@ -2444,7 +2451,7 @@
             // this error message as Change-Id should be unique per branch.
             //
             reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (changes.size() == 1) {
@@ -2469,13 +2476,13 @@
                 magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
               continue;
             }
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (changes.isEmpty()) {
             if (!isValidChangeId(p.changeKey.get())) {
               reject(magicBranch.cmd, "invalid Change-Id");
-              return Collections.emptyList();
+              return ImmutableList.of();
             }
 
             // In case the change look up from the index failed,
@@ -2483,7 +2490,7 @@
             if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-                return Collections.emptyList();
+                return ImmutableList.of();
               }
               itr.remove();
               continue;
@@ -2503,11 +2510,11 @@
 
       if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
         reject(magicBranch.cmd, "no new changes");
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
       if (!newChanges.isEmpty() && magicBranch.edit) {
         reject(magicBranch.cmd, "edit is not supported for new changes");
-        return newChanges;
+        return ImmutableList.copyOf(newChanges);
       }
 
       SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
@@ -2521,10 +2528,10 @@
         replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
       }
       for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+        update.groups = ImmutableList.copyOf(groups.get(update.commit));
       }
       logger.atFine().log("Finished updating groups from GroupCollector");
-      return newChanges;
+      return ImmutableList.copyOf(newChanges);
     }
   }
 
@@ -2665,7 +2672,11 @@
 
   private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
     try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) {
-      return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+      List<ChangeData> byBranchKeyExactMatch =
+          queryProvider.get().byBranchKey(magicBranch.dest, key).stream()
+              .filter(cd -> cd.change().getKey().equals(key))
+              .collect(toList());
+      return new ChangeLookup(c, key, byBranchKeyExactMatch);
     }
   }
 
@@ -2827,7 +2838,7 @@
     }
   }
 
-  private void preparePatchSetsForReplace(List<CreateRequest> newChanges) {
+  private void preparePatchSetsForReplace(ImmutableList<CreateRequest> newChanges) {
     try (TraceTimer traceTimer =
         newTimer(
             "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -2862,7 +2873,7 @@
     try (TraceTimer traceTimer = newTimer("readChangesForReplace")) {
       replaceByChange.values().stream()
           .map(r -> r.ontoChange)
-          .map(id -> notesFactory.create(project.getNameKey(), id))
+          .map(id -> notesFactory.create(repo, project.getNameKey(), id))
           .forEach(notes -> replaceByChange.get(notes.getChangeId()).notes = notes);
     }
   }
@@ -2990,9 +3001,7 @@
           return false;
         }
 
-        try {
-          permissions.change(notes).check(ChangePermission.ADD_PATCH_SET);
-        } catch (AuthException no) {
+        if (!permissions.change(notes).test(ChangePermission.ADD_PATCH_SET)) {
           reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
           return false;
         }
@@ -3039,18 +3048,8 @@
       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 {
-            permissions.change(notes).check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-          } catch (AuthException e1) {
+        if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
+          if (!permissions.change(notes).test(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)) {
             reject(inputCommand, ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP);
           }
         }
@@ -3179,22 +3178,20 @@
 
         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(),
-                    notes.getChange())
-                .setRequestScopePropagator(requestScopePropagator);
+            replaceOpFactory.create(
+                projectState,
+                notes.getChange(),
+                checkMergedInto,
+                checkMergedInto ? inputCommand.getNewId().name() : null,
+                priorPatchSet,
+                priorCommit,
+                psId,
+                newCommit,
+                info,
+                groups,
+                magicBranch,
+                receivePack.getPushCertificate(),
+                requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -3221,9 +3218,14 @@
       }
     }
 
+    @Nullable
     String getRejectMessage() {
       return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
+
+    Optional<String> getOutdatedApprovalsMessage() {
+      return replaceOp != null ? replaceOp.getOutdatedApprovalsMessage() : Optional.empty();
+    }
   }
 
   private class UpdateGroupsRequest {
@@ -3283,7 +3285,7 @@
       }
       if (isConfig(cmd)) {
         logger.atFine().log("Reloading project in cache");
-        projectCache.evict(project);
+        projectCache.evictAndReindex(project);
         ProjectState ps =
             projectCache.get(project.getNameKey()).orElseThrow(illegalState(project.getNameKey()));
         try {
@@ -3341,7 +3343,9 @@
   // REJECTED, and the return value is 'false'
   private boolean validRefOperation(ReceiveCommand cmd) {
     try (TraceTimer traceTimer = newTimer("validRefOperation")) {
-      RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
+      RefOperationValidators refValidators =
+          refValidatorsFactory.create(
+              getProject(), user, cmd, ImmutableListMultimap.copyOf(pushOptions));
 
       try {
         messages.addAll(refValidators.validateForRefOperation());
@@ -3454,7 +3458,7 @@
                 "autoCloseChanges",
                 updateFactory -> {
                   try (BatchUpdate bu =
-                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
+                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.now());
                       ObjectInserter ins = repo.newObjectInserter();
                       ObjectReader reader = ins.newReader();
                       RevWalk rw = new RevWalk(reader)) {
@@ -3476,7 +3480,7 @@
                     rw.markStart(newTip);
                     rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
 
-                    Map<Change.Key, ChangeNotes> byKey = null;
+                    Map<Change.Key, ChangeData> changeDataByKey = null;
                     List<ReplaceRequest> replaceAndClose = new ArrayList<>();
 
                     int existingPatchSets = 0;
@@ -3512,8 +3516,8 @@
 
                       for (String changeId :
                           ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get())) {
-                        if (byKey == null) {
-                          byKey =
+                        if (changeDataByKey == null) {
+                          changeDataByKey =
                               retryHelper
                                   .changeIndexQuery(
                                       "queryOpenChangesByKeyByBranch",
@@ -3521,14 +3525,15 @@
                                   .call();
                         }
 
-                        ChangeNotes onto = byKey.get(Change.key(changeId.trim()));
+                        ChangeData onto = changeDataByKey.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.
+                          ChangeNotes ontoNotes = onto.notes();
                           ReplaceRequest req =
-                              new ReplaceRequest(onto.getChangeId(), c, cmd, false);
-                          req.notes = onto;
+                              new ReplaceRequest(ontoNotes.getChangeId(), c, cmd, false);
+                          req.notes = ontoNotes;
                           replaceAndClose.add(req);
                           continue COMMIT;
                         }
@@ -3601,14 +3606,17 @@
     }
   }
 
-  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(
+  private Map<Change.Key, ChangeData> openChangesByKeyByBranch(
       InternalChangeQuery internalChangeQuery, BranchNameKey branch) {
     try (TraceTimer traceTimer =
         newTimer("openChangesByKeyByBranch", Metadata.builder().branchName(branch.branch()))) {
-      Map<Change.Key, ChangeNotes> r = new HashMap<>();
+      Map<Change.Key, ChangeData> r = new HashMap<>();
       for (ChangeData cd : internalChangeQuery.byBranchOpen(branch)) {
         try {
-          r.put(cd.change().getKey(), cd.notes());
+          // ChangeData is not materialised into a ChangeNotes for avoiding
+          // to load a potentially large number of changes meta-data into memory
+          // which would cause unnecessary disk I/O, CPU and heap utilisation.
+          r.put(cd.change().getKey(), cd);
         } catch (NoSuchChangeException e) {
           // Ignore deleted change
         }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index cc203ad..7c22bd8 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -91,7 +91,11 @@
         .filter(ReceiveCommitsAdvertiseRefsHook::skip)
         .collect(toImmutableList())
         .forEach(r -> advertisedRefs.remove(r));
-    rp.setAdvertisedRefs(advertisedRefs, advertiseOpenChanges(rp.getRepository()));
+    try {
+      rp.setAdvertisedRefs(advertisedRefs, advertiseOpenChanges(rp.getRepository()));
+    } catch (IOException e) {
+      throw new ServiceMayNotContinueException(e);
+    }
   }
 
   private Set<ObjectId> advertiseOpenChanges(Repository repo)
@@ -107,10 +111,10 @@
               .get()
               .setRequestedFields(
                   // Required for ChangeIsVisibleToPrdicate.
-                  ChangeField.CHANGE,
-                  ChangeField.REVIEWER,
+                  ChangeField.CHANGE_SPEC,
+                  ChangeField.REVIEWER_SPEC,
                   // Required during advertiseOpenChanges.
-                  ChangeField.PATCH_SET)
+                  ChangeField.PATCH_SET_SPEC)
               .enforceVisibility(true)
               .setLimit(limit)
               .query(
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index a9ef70e..0e17342 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.joining;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
@@ -28,7 +29,7 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
@@ -46,24 +47,26 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
-import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.change.ReviewerOp;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -75,6 +78,7 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -86,8 +90,6 @@
 import java.util.Map;
 import java.util.Optional;
 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;
@@ -102,7 +104,7 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        BranchNameKey dest,
+        Change change,
         boolean checkMergedInto,
         @Nullable String mergeResultRevId,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -113,30 +115,29 @@
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
         @Nullable PushCertificate pushCertificate,
-        Change change);
+        RequestScopePropagator requestScopePropagator);
   }
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
+  private final AccountCache accountCache;
   private final AccountResolver accountResolver;
+  private final String anonymousCowardName;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
-  private final ExecutorService sendEmailExecutor;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetUtil psUtil;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerModifier reviewerModifier;
-  private final Change change;
-  private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final ProjectState projectState;
-  private final BranchNameKey dest;
+  private final Change change;
   private final boolean checkMergedInto;
   private final String mergeResultRevId;
   private final PatchSet.Id priorPatchSetId;
@@ -146,6 +147,7 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
+  private final RequestScopePropagator requestScopePropagator;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -155,15 +157,17 @@
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
   private String mailMessage;
+  private ApprovalCopier.Result approvalCopierResult;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
-  private RequestScopePropagator requestScopePropagator;
   private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
   ReplaceOp(
+      AccountCache accountCache,
       AccountResolver accountResolver,
+      @AnonymousCowardName String anonymousCowardName,
       ApprovalsUtil approvalsUtil,
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
@@ -172,15 +176,12 @@
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetUtil psUtil,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       ReviewerModifier reviewerModifier,
-      Change change,
-      MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
-      @Assisted BranchNameKey dest,
+      @Assisted Change change,
       @Assisted boolean checkMergedInto,
       @Assisted @Nullable String mergeResultRevId,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -190,8 +191,11 @@
       @Assisted PatchSetInfo info,
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
-      @Assisted @Nullable PushCertificate pushCertificate) {
+      @Assisted @Nullable PushCertificate pushCertificate,
+      @Assisted RequestScopePropagator requestScopePropagator) {
+    this.accountCache = accountCache;
     this.accountResolver = accountResolver;
+    this.anonymousCowardName = anonymousCowardName;
     this.approvalsUtil = approvalsUtil;
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
@@ -200,16 +204,13 @@
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
     this.psUtil = psUtil;
-    this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
-    this.sendEmailExecutor = sendEmailExecutor;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.reviewerModifier = reviewerModifier;
-    this.change = change;
-    this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
 
     this.projectState = projectState;
-    this.dest = dest;
+    this.change = change;
     this.checkMergedInto = checkMergedInto;
     this.mergeResultRevId = mergeResultRevId;
     this.priorPatchSetId = priorPatchSetId;
@@ -220,6 +221,7 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.requestScopePropagator = requestScopePropagator;
   }
 
   @Override
@@ -235,7 +237,7 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
+      String mergedInto = findMergedInto(ctx, change.getDest().branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
@@ -338,16 +340,17 @@
     }
     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
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
     if (magicBranch != null && !magicBranch.labels.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    // Approvals that are being set in the new patch-set during this operation are not available yet
-    // outside of the scope of this method. Only copied approvals are set here.
-    approvalsUtil.byPatchSet(ctx.getNotes(), newPatchSet).forEach(a -> update.putCopiedApproval(a));
+    approvalCopierResult =
+        approvalsUtil.copyApprovalsToNewPatchSet(
+            ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -402,13 +405,12 @@
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
 
     return input;
   }
 
-  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage)
-      throws IOException {
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage) {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
             patchSetId.get(), approvals, scanLabels(ctx, approvals));
@@ -422,6 +424,15 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n\n").append(reviewMessage);
     }
+    approvalsUtil
+        .formatApprovalCopierResult(approvalCopierResult, projectState.getLabelTypes())
+        .ifPresent(
+            msg -> {
+              if (Strings.isNullOrEmpty(reviewMessage) || !reviewMessage.endsWith("\n")) {
+                message.append("\n");
+              }
+              message.append("\n").append(msg);
+            });
     boolean workInProgress = ctx.getChange().isWorkInProgress();
     if (magicBranch != null && magicBranch.workInProgress) {
       workInProgress = true;
@@ -430,6 +441,7 @@
         update, message.toString(), ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
   }
 
+  @Nullable
   private String changeKindMessage(ChangeKind changeKind) {
     switch (changeKind) {
       case MERGE_FIRST_PARENT_UPDATE:
@@ -439,8 +451,8 @@
       case TRIVIAL_REBASE:
         return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
       case NO_CHANGE:
-        return ": New patch set was added with same tree, parent"
-            + (commit.getParentCount() != 1 ? "s" : "")
+        return ": New patch set was added with same tree, parent "
+            + (commit.getParentCount() != 1 ? "trees" : "tree")
             + ", and commit message as Patch Set "
             + priorPatchSetId.get()
             + ".";
@@ -452,18 +464,13 @@
     }
   }
 
-  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
-      throws IOException {
+  private Map<String, PatchSetApproval> scanLabels(
+      ChangeContext ctx, Map<String, Short> approvals) {
     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.getNotes(),
-              priorPatchSetId,
-              ctx.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
+          approvalsUtil.byPatchSetUser(ctx.getNotes(), priorPatchSetId, ctx.getAccountId())) {
         if (a.isLegacySubmit()) {
           continue;
         }
@@ -496,16 +503,30 @@
   @Override
   public void postUpdate(PostUpdateContext 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);
-      if (requestScopePropagator != null) {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
-      } else {
-        e.run();
-      }
-    }
+
+    // TODO(dborowitz): Merge email templates so we only have to send one.
+    emailNewPatchSetFactory
+        .create(
+            ctx,
+            newPatchSet,
+            mailMessage,
+            approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                .collect(toImmutableSet()),
+            Streams.concat(
+                    oldRecipients.getReviewers().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId))
+                .collect(toImmutableSet()),
+            Streams.concat(
+                    oldRecipients.getCcOnly().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
+                .collect(toImmutableSet()),
+            changeKind,
+            notes.getMetaId())
+        .setRequestScopePropagator(requestScopePropagator)
+        .sendAsync();
+
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     revisionCreated.fire(
         ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
@@ -519,49 +540,6 @@
     }
   }
 
-  private class ReplaceEmailTask implements Runnable {
-    private final Context ctx;
-
-    private ReplaceEmailTask(Context ctx) {
-      this.ctx = ctx;
-    }
-
-    @Override
-    public void run() {
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        emailSender.setFrom(ctx.getAccount().account().id());
-        emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
-        emailSender.addReviewers(
-            Streams.concat(
-                    oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::accountId))
-                .collect(toImmutableSet()));
-        emailSender.addExtraCC(
-            Streams.concat(
-                    oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
-                .collect(toImmutableSet()));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
-        // TODO(dborowitz): Support byEmail
-        emailSender.send();
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.id());
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "send-email newpatchset";
-    }
-  }
-
   private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
@@ -611,15 +589,46 @@
     return rejectMessage;
   }
 
+  public Optional<String> getOutdatedApprovalsMessage() {
+    if (approvalCopierResult == null || approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    return Optional.of(
+        "The following approvals got outdated and were removed:\n"
+            + approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                .map(
+                    outdatedApproval ->
+                        String.format(
+                            "* %s by %s",
+                            LabelVote.create(outdatedApproval.label(), outdatedApproval.value())
+                                .format(),
+                            getNameFor(outdatedApproval.accountId())))
+                .sorted()
+                .collect(joining("\n")));
+  }
+
+  private String getNameFor(Account.Id accountId) {
+    Optional<Account> account = accountCache.get(accountId).map(AccountState::account);
+    String name = null;
+    if (account.isPresent()) {
+      name = account.get().fullName();
+      if (name == null) {
+        name = account.get().preferredEmail();
+      }
+    }
+    if (name == null) {
+      name = anonymousCowardName + " #" + accountId;
+    }
+    return name;
+  }
+
   public ReceiveCommand getCommand() {
     return cmd;
   }
 
-  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
-    this.requestScopePropagator = requestScopePropagator;
-    return this;
-  }
-
+  @Nullable
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RevWalk rw = ctx.getRevWalk();
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
index c54ab25..4d2805d 100644
--- a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -19,6 +19,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -37,12 +39,13 @@
   @VisibleForTesting
   @AutoValue
   public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
+    public abstract ImmutableMap<String, Ref> allRefs();
 
-    public abstract Set<ObjectId> additionalHaves();
+    public abstract ImmutableSet<ObjectId> additionalHaves();
 
     public static Result create(Map<String, Ref> allRefs, Set<ObjectId> additionalHaves) {
-      return new AutoValue_TestRefAdvertiser_Result(allRefs, additionalHaves);
+      return new AutoValue_TestRefAdvertiser_Result(
+          ImmutableMap.copyOf(allRefs), ImmutableSet.copyOf(additionalHaves));
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 4755f5f..b38f405 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -28,7 +28,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -77,7 +76,7 @@
       }
     }
 
-    List<String> messages = new ArrayList<>();
+    ImmutableList.Builder<String> messages = ImmutableList.builder();
     Optional<Account> newAccount;
     try {
       newAccount = loadAccount(accountId, allUsersRepo, rw, newId, messages);
@@ -92,7 +91,7 @@
       return ImmutableList.of(String.format("account '%s' does not exist", accountId.get()));
     }
 
-    if (accountId.equals(self.get().getAccountId()) && !newAccount.get().isActive()) {
+    if (!newAccount.get().isActive() && accountId.equals(self.get().getAccountId())) {
       messages.add("cannot deactivate own account");
     }
 
@@ -108,7 +107,7 @@
       }
     }
 
-    return ImmutableList.copyOf(messages);
+    return messages.build();
   }
 
   private Optional<Account> loadAccount(
@@ -116,7 +115,7 @@
       Repository allUsersRepo,
       RevWalk rw,
       ObjectId commit,
-      @Nullable List<String> messages)
+      @Nullable ImmutableList.Builder<String> messages)
       throws IOException, ConfigInvalidException {
     rw.reset();
     AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo);
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index 6e640f3..b887323 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -32,7 +31,8 @@
 
 /**
  * Limits the total size of all comments and change messages to prevent space/time complexity
- * issues. Note that autogenerated change messages are not subject to validation.
+ * issues. Note that autogenerated change messages are not subject to validation. However, we still
+ * count autogenerated messages for the limit (which will be notified on a further comment).
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
   public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
@@ -60,17 +60,11 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream()
-                // Auto-generated change messages are not counted for the limit. This method is not
-                // called when those change messages are created, but we should also skip them when
-                // counting the size for unrelated messages.
-                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
-                .mapToInt(cm -> cm.getMessage().length())
-                .sum();
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
-    if (!comments.isEmpty() && existingCumulativeSize + newCumulativeSize > maxCumulativeSize) {
+    if (!comments.isEmpty() && !isEnoughSpace(notes, newCumulativeSize, maxCumulativeSize)) {
       // This warning really applies to the set of all comments, but we need to pick one to attach
       // the message to.
       CommentForValidation commentForFailureMessage = Iterables.getLast(comments);
@@ -84,4 +78,19 @@
     }
     return failures.build();
   }
+
+  /**
+   * Returns {@code true} if there is available space and the new size that we wish to add is less
+   * than the maximum allowed size. {@code false} otherwise (if there is not enough space).
+   */
+  public static boolean isEnoughSpace(ChangeNotes notes, int addedBytes, int maxCumulativeSize) {
+    int existingCumulativeSize =
+        Stream.concat(
+                    notes.getHumanComments().values().stream(),
+                    notes.getRobotComments().values().stream())
+                .mapToInt(Comment::getApproximateSize)
+                .sum()
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+    return existingCumulativeSize + addedBytes < maxCumulativeSize;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 056407e..999f810 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -22,6 +22,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
@@ -48,10 +49,12 @@
 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.patch.DiffOperations;
 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.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -104,6 +107,7 @@
     private final AccountValidator accountValidator;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final DiffOperations diffOperations;
     private final Config config;
 
     @Inject
@@ -118,7 +122,8 @@
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
         ProjectCache projectCache,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        DiffOperations diffOperations) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.config = config;
@@ -130,6 +135,7 @@
       this.accountValidator = accountValidator;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
+      this.diffOperations = diffOperations;
     }
 
     public CommitValidators forReceiveCommits(
@@ -161,7 +167,8 @@
           .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -190,7 +197,8 @@
           .add(new PluginCommitValidationListener(pluginValidators))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -371,14 +379,18 @@
       // If there are no SSH keys, the commit-msg hook must be installed via
       // HTTP(S)
       Optional<String> webUrl = urlFormatter.getWebUrl();
+
+      String httpHook =
+          String.format(
+              "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
+              webUrl.get());
+
       if (hostKeys.isEmpty()) {
         checkState(webUrl.isPresent());
-        return String.format(
-            "  f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
-            webUrl.get());
+        return httpHook;
       }
 
-      // SSH keys exist, so the hook can be installed with scp.
+      // SSH keys exist, so the hook might be able to be installed with scp.
       String sshHost;
       int sshPort;
       String host = hostKeys.get(0).getHost();
@@ -396,9 +408,17 @@
         sshPort = 22;
       }
 
-      return String.format(
-          "  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
-          sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      // TODO(15944): Remove once both SFTP/SCP protocol are supported.
+      //
+      // In newer versions of OpenSSH, the default hook installation command will fail with a
+      // cryptic error because the scp binary defaults to a different protocol.
+      String scpFlagHint = "(for OpenSSH >= 9.0 you need to add the flag '-O' to the scp command)";
+
+      String sshHook =
+          String.format(
+              "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
+              sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      return String.format("  %s\n%s\nor, for http(s):\n  %s", sshHook, scpFlagHint, httpHook);
     }
   }
 
@@ -441,7 +461,7 @@
         // This happens e.g. for cherrypicks.
         if (!receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
           logger.atWarning().withCause(e).log(
-              "Failed to validate file count for commit: %s", receiveEvent.commit.toString());
+              "Failed to validate file count for commit: %s", receiveEvent.commit);
         }
       }
       return Collections.emptyList();
@@ -502,7 +522,7 @@
             for (ValidationError err : cfg.getValidationErrors()) {
               addError("  " + err.getMessage(), messages);
             }
-            throw new ConfigInvalidException("invalid project configuration");
+            throw new CommitValidationException("invalid project configuration", messages);
           }
           if (allUsers.equals(receiveEvent.project.getNameKey())
               && !allProjects.equals(cfg.getProject().getParent(allProjects))) {
@@ -510,9 +530,12 @@
             addError(
                 String.format("  %s must inherit from %s", allUsers.get(), allProjects.get()),
                 messages);
-            throw new ConfigInvalidException("invalid project configuration");
+            throw new CommitValidationException("invalid project configuration", messages);
           }
         } catch (ConfigInvalidException | IOException e) {
+          if (e instanceof ConfigInvalidException && !Strings.isNullOrEmpty(e.getMessage())) {
+            addError(e.getMessage(), messages);
+          }
           logger.atSevere().withCause(e).log(
               "User %s tried to push an invalid project configuration %s for project %s",
               user.getLoggableName(),
@@ -541,10 +564,10 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.MERGE);
-        return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException("you are not allowed to upload merges", e);
+        if (perm.test(RefPermission.MERGE)) {
+          return Collections.emptyList();
+        }
+        throw new CommitValidationException("you are not allowed to upload merges");
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check MERGE");
         throw new CommitValidationException("internal auth error");
@@ -639,10 +662,10 @@
       }
       if (!sboAuthor && !sboCommitter && !sboMe) {
         try {
-          perm.check(RefPermission.FORGE_COMMITTER);
-        } catch (AuthException denied) {
-          throw new CommitValidationException(
-              "not Signed-off-by author/committer/uploader in message footer", denied);
+          if (!perm.test(RefPermission.FORGE_COMMITTER)) {
+            throw new CommitValidationException(
+                "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");
@@ -673,11 +696,11 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.FORGE_AUTHOR);
+        if (!perm.test(RefPermission.FORGE_AUTHOR)) {
+          throw new CommitValidationException(
+              "invalid author", invalidEmail("author", author, user, urlFormatter));
+        }
         return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid author", invalidEmail("author", author, user, urlFormatter), e);
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR");
         throw new CommitValidationException("internal auth error");
@@ -706,11 +729,11 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.FORGE_COMMITTER);
+        if (!perm.test(RefPermission.FORGE_COMMITTER)) {
+          throw new CommitValidationException(
+              "invalid committer", invalidEmail("committer", committer, user, urlFormatter));
+        }
         return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid committer", invalidEmail("committer", committer, user, urlFormatter), e);
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
         throw new CommitValidationException("internal auth error");
@@ -778,9 +801,7 @@
         }
         return Collections.emptyList();
       } catch (IOException e) {
-        String m = "error checking banned commits";
-        logger.atWarning().withCause(e).log(m);
-        throw new CommitValidationException(m, e);
+        throw new CommitValidationException("error checking banned commits", e);
       }
     }
   }
@@ -819,9 +840,7 @@
           }
           return msgs;
         } catch (IOException | ConfigInvalidException e) {
-          String m = "error validating external IDs";
-          logger.atWarning().withCause(e).log(m);
-          throw new CommitValidationException(m, e);
+          throw new CommitValidationException("error validating external IDs", e);
         }
       }
       return Collections.emptyList();
@@ -876,9 +895,8 @@
                   .collect(toList()));
         }
       } catch (IOException e) {
-        String m = String.format("Validating update for account %s failed", accountId.get());
-        logger.atSevere().withCause(e).log(m);
-        throw new CommitValidationException(m, e);
+        throw new CommitValidationException(
+            String.format("Validating update for account %s failed", accountId.get()), e);
       }
       return Collections.emptyList();
     }
@@ -969,6 +987,9 @@
       try {
         return new URL(canonicalWebUrl).getHost();
       } catch (MalformedURLException ignored) {
+        logger.atWarning().log(
+            "configured canonical web URL is invalid, using system default: %s",
+            ignored.getMessage());
       }
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 6b145ca..40ce671 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -195,9 +195,9 @@
             if (!oldParent.equals(newParent)) {
               if (!allowProjectOwnersToChangeParent) {
                 try {
-                  permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-                } catch (AuthException e) {
-                  throw new MergeValidationException(SET_BY_ADMIN, e);
+                  if (!permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+                    throw new MergeValidationException(SET_BY_ADMIN);
+                  }
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
                   throw new MergeValidationException("validation unavailable", e);
@@ -235,7 +235,7 @@
             String oldValue =
                 destProject.getPluginConfig(e.getPluginName()).getString(e.getExportName());
 
-            if ((!Objects.equals(value, oldValue)) && !configEntry.isEditable(destProject)) {
+            if (!Objects.equals(value, oldValue) && !configEntry.isEditable(destProject)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index f3b6983..9ac3c89 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -16,6 +16,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -33,6 +34,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -46,7 +48,11 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
+    RefOperationValidators create(
+        Project project,
+        IdentifiedUser user,
+        ReceiveCommand cmd,
+        ImmutableListMultimap<String, String> pushOptions);
   }
 
   public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
@@ -66,7 +72,8 @@
       PluginSetContext<RefOperationValidationListener> refOperationValidationListeners,
       @Assisted Project project,
       @Assisted IdentifiedUser user,
-      @Assisted ReceiveCommand cmd) {
+      @Assisted ReceiveCommand cmd,
+      @Assisted ImmutableListMultimap<String, String> pushOptions) {
     this.perm = permissionBackend.user(user);
     this.allUsersName = allUsersName;
     this.refOperationValidationListeners = refOperationValidationListeners;
@@ -74,6 +81,7 @@
     event.command = cmd;
     event.project = project;
     event.user = user;
+    event.pushOptions = pushOptions;
   }
 
   /**
@@ -89,7 +97,7 @@
           new DisallowCreationAndDeletionOfGerritMaintainedBranches(perm, allUsersName)
               .onRefOperation(event));
       refOperationValidationListeners.runEach(
-          l -> l.onRefOperation(event), ValidationException.class);
+          l -> messages.addAll(l.onRefOperation(event)), ValidationException.class);
     } catch (ValidationException e) {
       messages.add(new ValidationMessage(e.getMessage(), true));
       withException = true;
@@ -106,13 +114,30 @@
       throws RefOperationValidationException {
     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);
+            "Validation for %s of ref '%s' in project %s failed:",
+            formatReceiveCommandType(event.command.getType()),
+            event.command.getRefName(),
+            event.project.getName());
+    logger.atSevere().log("%s", header);
     throw new RefOperationValidationException(
         header, messages.stream().filter(ValidationMessage::isError).collect(toImmutableList()));
   }
 
+  private static String formatReceiveCommandType(ReceiveCommand.Type type) {
+    switch (type) {
+      case CREATE:
+        return "creation";
+      case DELETE:
+        return "deletion";
+      case UPDATE:
+        return "update";
+      case UPDATE_NONFASTFORWARD:
+        return "non-fast-forward update";
+      default:
+        return type.toString().toLowerCase(Locale.US);
+    }
+  }
+
   private static class DisallowCreationAndDeletionOfGerritMaintainedBranches
       implements RefOperationValidationListener {
     private final PermissionBackend.WithUser perm;
@@ -132,8 +157,10 @@
             && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT)) {
           if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
             try {
-              perm.check(GlobalPermission.ACCESS_DATABASE);
-            } catch (AuthException | PermissionBackendException e) {
+              if (!perm.test(GlobalPermission.ACCESS_DATABASE)) {
+                throw new ValidationException("Not allowed to create user branch.");
+              }
+            } catch (PermissionBackendException e) {
               throw new ValidationException("Not allowed to create user branch.", e);
             }
             if (Account.Id.fromRef(refEvent.command.getRefName()) == null) {
diff --git a/java/com/google/gerrit/server/group/GroupAuditService.java b/java/com/google/gerrit/server/group/GroupAuditService.java
index 30e5d3c..21959ec 100644
--- a/java/com/google/gerrit/server/group/GroupAuditService.java
+++ b/java/com/google/gerrit/server/group/GroupAuditService.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.AuditEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public interface GroupAuditService {
   void dispatch(AuditEvent action);
@@ -27,23 +27,23 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> addedMembers,
-      Timestamp addedOn);
+      Instant addedOn);
 
   void dispatchDeleteMembers(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> deletedMembers,
-      Timestamp deletedOn);
+      Instant deletedOn);
 
   void dispatchAddSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> addedSubgroups,
-      Timestamp addedOn);
+      Instant addedOn);
 
   void dispatchDeleteSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> deletedSubgroups,
-      Timestamp deletedOn);
+      Instant deletedOn);
 }
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 546614c..001a153 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -84,6 +85,7 @@
    * @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
    */
+  @Nullable
   public GroupDescription.Basic parseId(String id) {
     logger.atFine().log("Parsing group %s", id);
 
diff --git a/java/com/google/gerrit/server/group/GroupResource.java b/java/com/google/gerrit/server/group/GroupResource.java
index b0e81ec..dfcdbd7 100644
--- a/java/com/google/gerrit/server/group/GroupResource.java
+++ b/java/com/google/gerrit/server/group/GroupResource.java
@@ -22,8 +22,7 @@
 import java.util.Optional;
 
 public class GroupResource implements RestResource {
-  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND =
-      new TypeLiteral<RestView<GroupResource>>() {};
+  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND = new TypeLiteral<>() {};
 
   private final GroupControl control;
 
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index 62ebcfe..984daea 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.InternalGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
 
@@ -77,7 +77,7 @@
   }
 
   @Override
-  public Timestamp getCreatedOn() {
+  public Instant getCreatedOn() {
     return internalGroup.getCreatedOn();
   }
 
diff --git a/java/com/google/gerrit/server/group/MemberResource.java b/java/com/google/gerrit/server/group/MemberResource.java
index b12cadd..de8cc02 100644
--- a/java/com/google/gerrit/server/group/MemberResource.java
+++ b/java/com/google/gerrit/server/group/MemberResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class MemberResource extends GroupResource {
-  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND =
-      new TypeLiteral<RestView<MemberResource>>() {};
+  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND = new TypeLiteral<>() {};
 
   private final IdentifiedUser user;
 
diff --git a/java/com/google/gerrit/server/group/SubgroupResource.java b/java/com/google/gerrit/server/group/SubgroupResource.java
index 21356be..7d917a7 100644
--- a/java/com/google/gerrit/server/group/SubgroupResource.java
+++ b/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -21,7 +21,7 @@
 
 public class SubgroupResource extends GroupResource {
   public static final TypeLiteral<RestView<SubgroupResource>> SUBGROUP_KIND =
-      new TypeLiteral<RestView<SubgroupResource>>() {};
+      new TypeLiteral<>() {};
 
   private final GroupDescription.Basic member;
 
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 5d50d22..0471acc 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -21,6 +21,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -45,9 +46,9 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Optional;
 import java.util.Set;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -89,7 +90,7 @@
   }
 
   private final ImmutableSet<String> reservedNames;
-  private final SortedMap<String, GroupReference> namesToGroups;
+  private final NavigableMap<String, GroupReference> namesToGroups;
   private final ImmutableSet<String> names;
   private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
   private final ImmutableSet<AccountGroup.UUID> externalUserMemberships;
@@ -97,7 +98,7 @@
   @Inject
   @VisibleForTesting
   public SystemGroupBackend(@GerritServerConfig Config cfg) {
-    SortedMap<String, GroupReference> n = new TreeMap<>();
+    NavigableMap<String, GroupReference> n = new TreeMap<>();
     ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u = ImmutableMap.builder();
 
     ImmutableSet.Builder<String> reservedNamesBuilder = ImmutableSet.builder();
@@ -112,7 +113,7 @@
       u.put(ref.getUUID(), ref);
     }
     reservedNames = reservedNamesBuilder.build();
-    namesToGroups = Collections.unmodifiableSortedMap(n);
+    namesToGroups = Collections.unmodifiableNavigableMap(n);
     names =
         ImmutableSet.copyOf(
             namesToGroups.values().stream().map(GroupReference::getName).collect(toSet()));
@@ -140,6 +141,7 @@
     return isSystemGroup(uuid);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     final GroupReference ref = uuids.get(uuid);
@@ -157,11 +159,13 @@
         return ref.getUUID();
       }
 
+      @Nullable
       @Override
       public String getUrl() {
         return null;
       }
 
+      @Nullable
       @Override
       public String getEmailAddress() {
         return null;
@@ -172,9 +176,10 @@
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     String nameLC = name.toLowerCase(Locale.US);
-    SortedMap<String, GroupReference> matches = namesToGroups.tailMap(nameLC);
+    NavigableMap<String, GroupReference> matches =
+        namesToGroups.tailMap(nameLC, /* inclusive= */ true);
     if (matches.isEmpty()) {
-      return Collections.emptyList();
+      return new ArrayList<>();
     }
 
     List<GroupReference> r = new ArrayList<>(matches.size());
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index d8f0a0f..4c1f69b 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -166,7 +166,7 @@
     return Optional.of(
         new AutoValue_AuditLogReader_ParsedCommit(
             authorId.get(),
-            new Timestamp(c.getAuthorIdent().getWhen().getTime()),
+            c.getAuthorIdent().getWhenAsInstant(),
             ImmutableList.copyOf(addedMembers),
             ImmutableList.copyOf(removedMembers),
             ImmutableList.copyOf(addedSubgroups),
@@ -257,7 +257,7 @@
   abstract static class ParsedCommit {
     abstract Account.Id authorId();
 
-    abstract Timestamp when();
+    abstract Instant when();
 
     abstract ImmutableList<Account.Id> addedMembers();
 
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index c187186..4f2c049 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 import java.util.function.Function;
@@ -279,7 +279,7 @@
       rw.markStart(revision);
       rw.sort(RevSort.REVERSE);
       RevCommit earliestCommit = rw.next();
-      Timestamp createdOn = new Timestamp(earliestCommit.getCommitTime() * 1000L);
+      Instant createdOn = Instant.ofEpochSecond(earliestCommit.getCommitTime());
 
       Config config = readConfig(GROUP_CONFIG_FILE);
       ImmutableSet<Account.Id> members = readMembers();
@@ -314,9 +314,9 @@
 
     // Commit timestamps are internally truncated to seconds. To return the correct 'createdOn' time
     // for new groups, we explicitly need to truncate the timestamp here.
-    Timestamp commitTimestamp =
+    Instant commitTimestamp =
         TimeUtil.truncateToSecond(
-            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::nowTs));
+            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
     commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
     commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
@@ -346,7 +346,7 @@
     return Optional.empty();
   }
 
-  private InternalGroup updateGroup(Timestamp commitTimestamp)
+  private InternalGroup updateGroup(Instant commitTimestamp)
       throws IOException, ConfigInvalidException {
     Config config = updateGroupProperties();
 
@@ -358,7 +358,7 @@
         loadedGroup.map(InternalGroup::getSubgroups).orElseGet(ImmutableSet::of);
     Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups = updateSubgroups(originalSubgroups);
 
-    Timestamp createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
+    Instant createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
 
     return createFrom(
         groupUuid,
@@ -453,7 +453,7 @@
       Config config,
       ImmutableSet<Account.Id> members,
       ImmutableSet<AccountGroup.UUID> subgroups,
-      Timestamp createdOn,
+      Instant createdOn,
       ObjectId refState)
       throws ConfigInvalidException {
     InternalGroup.Builder group = InternalGroup.builder();
diff --git a/java/com/google/gerrit/server/group/db/GroupDelta.java b/java/com/google/gerrit/server/group/db/GroupDelta.java
index 69cb936..ad9c8bd 100644
--- a/java/com/google/gerrit/server/group/db/GroupDelta.java
+++ b/java/com/google/gerrit/server/group/db/GroupDelta.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import java.util.Set;
 
@@ -107,7 +107,7 @@
    * in the audit log. For this reason, specifying this field will have an effect on the resulting
    * audit log.
    */
-  public abstract Optional<Timestamp> getUpdatedOn();
+  public abstract Optional<Instant> getUpdatedOn();
 
   public abstract Builder toBuilder();
 
@@ -184,12 +184,12 @@
     public abstract SubgroupModification getSubgroupModification();
 
     /**
-     * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
-     * specified, the current {@code Timestamp} when creating the commit will be used.
+     * Defines the {@code Instant} to be used for the NoteDb commits of the update. If not
+     * specified, the current {@code Instant} when creating the commit will be used.
      *
      * <p>See {@link #getUpdatedOn()}
      */
-    public abstract Builder setUpdatedOn(Timestamp timestamp);
+    public abstract Builder setUpdatedOn(Instant timestamp);
 
     public abstract GroupDelta build();
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 24bcaf0..dd8534d 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -231,7 +231,7 @@
 
   public static void ensureConsistentWithGroupNameNotes(
       Repository allUsersRepo, InternalGroup group) throws IOException {
-    List<ConsistencyCheckInfo.ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyCheckInfo.ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, group.getNameKey(), group.getGroupUUID());
     problems.forEach(GroupsNoteDbConsistencyChecker::logConsistencyProblem);
@@ -246,7 +246,7 @@
    * @return a list of {@code ConsistencyProblemInfo} containing the problem details.
    */
   @VisibleForTesting
-  static List<ConsistencyProblemInfo> checkWithGroupNameNotes(
+  static ImmutableList<ConsistencyProblemInfo> checkWithGroupNameNotes(
       Repository allUsersRepo, AccountGroup.NameKey groupName, AccountGroup.UUID groupUUID)
       throws IOException {
     try {
@@ -259,7 +259,7 @@
 
       AccountGroup.UUID uuid = groupRef.get().getUUID();
 
-      List<ConsistencyProblemInfo> problems = new ArrayList<>();
+      ImmutableList.Builder<ConsistencyProblemInfo> problems = ImmutableList.builder();
       if (!Objects.equals(groupUUID, uuid)) {
         problems.add(
             warning(
@@ -273,7 +273,7 @@
         problems.add(
             warning("group note of name '%s' claims to represent name of '%s'", name, actualName));
       }
-      return problems;
+      return problems.build();
     } catch (ConfigInvalidException e) {
       return ImmutableList.of(
           warning("fail to check consistency with group name notes: %s", e.getMessage()));
@@ -287,9 +287,9 @@
 
   public static void logConsistencyProblem(ConsistencyProblemInfo p) {
     if (p.status == ConsistencyProblemInfo.Status.WARNING) {
-      logger.atWarning().log(p.message);
+      logger.atWarning().log("%s", p.message);
     } else {
-      logger.atSevere().log(p.message);
+      logger.atSevere().log("%s", p.message);
     }
   }
 
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 9aa5cfd..87d8db1 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -50,7 +50,7 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -115,7 +115,6 @@
   private final RetryHelper retryHelper;
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -150,7 +149,6 @@
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -185,7 +183,6 @@
         Optional.of(currentUser));
   }
 
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   private GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -248,7 +245,7 @@
   }
 
   private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    return user.newCommitterIdent(ident);
   }
 
   /**
@@ -292,9 +289,9 @@
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
-      Optional<Timestamp> updatedOn = groupDelta.getUpdatedOn();
+      Optional<Instant> updatedOn = groupDelta.getUpdatedOn();
       if (!updatedOn.isPresent()) {
-        updatedOn = Optional.of(TimeUtil.nowTs());
+        updatedOn = Optional.of(TimeUtil.now());
         groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
       }
 
@@ -505,7 +502,7 @@
     }
   }
 
-  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Timestamp updatedOn) {
+  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Instant updatedOn) {
     if (!currentUser.isPresent()) {
       return;
     }
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index 843b346..257bc16 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -123,7 +123,7 @@
       //
       GroupReference ref = config.getGroup(uuid);
       if (ref == null || newName.equals(ref.getName())) {
-        projectCache.evict(config.getProject());
+        projectCache.evictAndReindex(config.getProject());
         return;
       }
 
@@ -132,7 +132,7 @@
       md.setMessage("Rename group " + oldName + " to " + newName + "\n");
       try {
         config.commit(md);
-        projectCache.evict(config.getProject());
+        projectCache.evictAndReindex(config.getProject());
         success = true;
       } catch (IOException e) {
         logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 77bb777..bb2b20d 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -7,6 +7,7 @@
     testonly = True,
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
index 8a1221e..f422f6a 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -24,7 +24,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class InternalGroupSubject extends Subject {
@@ -79,7 +79,7 @@
     return check("isVisibleToAll()").that(group.isVisibleToAll());
   }
 
-  public ComparableSubject<Timestamp> createdOn() {
+  public ComparableSubject<Instant> createdOn() {
     isNotNull();
     return check("getCreatedOn()").that(group.getCreatedOn());
   }
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 601ac59..b0e270c 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -32,7 +33,7 @@
 
 /** Implementation of GroupBackend for tests. */
 public class TestGroupBackend implements GroupBackend {
-  private static final String PREFIX = "testbackend:";
+  public static final String PREFIX = "testbackend:";
 
   private final Map<AccountGroup.UUID, GroupDescription.Basic> groups = new HashMap<>();
   private final Map<Account.Id, GroupMembership> memberships = new HashMap<>();
@@ -111,6 +112,7 @@
     return false;
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     return uuid == null ? null : groups.get(uuid);
@@ -118,6 +120,13 @@
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
+    AccountGroup.UUID uuid = AccountGroup.uuid(name);
+    if (handles(uuid)) {
+      GroupDescription.Basic g = get(uuid);
+      if (g != null) {
+        return ImmutableList.of(GroupReference.forGroup(g));
+      }
+    }
     return ImmutableList.of();
   }
 
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 352971f..81c517f 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -33,6 +33,7 @@
  * index implementations, such as {@link com.google.gerrit.lucene.LuceneIndexModule}.
  */
 public abstract class AbstractIndexModule extends AbstractModule {
+  public static final String INDEX_MODULE = "index-module";
 
   private final int threads;
   private final Map<String, Integer> singleVersions;
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index a2ef070..9ad7cdb 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -53,6 +53,7 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.GroupIndexerImpl;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.index.project.ProjectIndexDefinition;
 import com.google.gerrit.server.index.project.ProjectIndexerImpl;
 import com.google.inject.Inject;
@@ -150,6 +151,9 @@
     }
 
     DynamicSet.setOf(binder(), OnlineUpgradeListener.class);
+    OptionalBinder.newOptionalBinder(binder(), IsFirstInsertForEntry.class)
+        .setDefault()
+        .toInstance(IsFirstInsertForEntry.NO);
   }
 
   @Provides
@@ -215,13 +219,14 @@
       return interactiveExecutor;
     }
     int threads = this.threads;
-    if (threads < 0) {
-      return MoreExecutors.newDirectExecutorService();
-    } else if (threads == 0) {
+    if (threads == 0) {
       threads =
           config.getInt(
               "index", null, "threads", Runtime.getRuntime().availableProcessors() / 2 + 1);
     }
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    }
     return MoreExecutors.listeningDecorator(
         workQueue.createQueue(threads, "Index-Interactive", true));
   }
@@ -234,11 +239,13 @@
     if (batchExecutor != null) {
       return batchExecutor;
     }
-    int threads = config.getInt("index", null, "batchThreads", 0);
+    int threads = this.threads;
+    if (threads == 0) {
+      threads =
+          config.getInt("index", null, "batchThreads", Runtime.getRuntime().availableProcessors());
+    }
     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 ee8dfc8..352d376 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -14,22 +14,19 @@
 
 package com.google.gerrit.server.index;
 
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
@@ -65,29 +62,29 @@
    */
   public static Set<String> accountFields(Set<String> fields, boolean useLegacyNumericFields) {
     String idFieldName =
-        useLegacyNumericFields ? AccountField.ID.getName() : AccountField.ID_STR.getName();
+        useLegacyNumericFields
+            ? AccountField.ID_FIELD_SPEC.getName()
+            : AccountField.ID_STR_FIELD_SPEC.getName();
     return fields.contains(idFieldName) ? fields : Sets.union(fields, ImmutableSet.of(idFieldName));
   }
 
   /**
    * Returns a sanitized set of fields for change index queries by removing fields that the current
-   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
-   * key situation is temporary and should be removed after the migration is done.
+   * index version doesn't support.
    */
-  public static Set<String> changeFields(QueryOptions opts, boolean useLegacyNumericFields) {
-    FieldDef<ChangeData, ?> idField = useLegacyNumericFields ? LEGACY_ID : LEGACY_ID_STR;
+  public static Set<String> changeFields(QueryOptions opts) {
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
     Set<String> fs = opts.fields();
-    if (fs.contains(CHANGE.getName())) {
+    if (fs.contains(CHANGE_SPEC.getName())) {
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT.getName()) && fs.contains(idField.getName())) {
+    if (fs.contains(PROJECT_SPEC.getName()) && fs.contains(NUMERIC_ID_STR_SPEC.getName())) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(idField.getName(), PROJECT.getName()));
+    return Sets.union(fs, ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName()));
   }
 
   /**
@@ -97,9 +94,9 @@
    */
   public static Set<String> groupFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(GroupField.UUID.getName())
+    return fs.contains(GroupField.UUID_FIELD_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
+        : Sets.union(fs, ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName()));
   }
 
   /** Returns a index-friendly representation of a {@link CurrentUser} to be used in queries. */
@@ -119,9 +116,9 @@
    */
   public static Set<String> projectFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(ProjectField.NAME.getName())
+    return fs.contains(ProjectField.NAME_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME.getName()));
+        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME_SPEC.getName()));
   }
 
   private IndexUtils() {
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 0dd22ce..ed58a0b 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,11 +14,6 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.integer;
-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 java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.FluentIterable;
@@ -26,7 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.server.account.AccountState;
@@ -41,13 +36,31 @@
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for accounts. */
+/**
+ * Secondary index schemas for accounts.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class AccountField {
-  public static final FieldDef<AccountState, Integer> ID =
-      integer("id").stored().build(a -> a.account().id().get());
 
-  public static final FieldDef<AccountState, String> ID_STR =
-      exact("id_str").stored().build(a -> String.valueOf(a.account().id().get()));
+  public static final IndexedField<AccountState, Integer> ID_FIELD =
+      IndexedField.<AccountState>integerBuilder("Id")
+          .stored()
+          .required()
+          .build(a -> a.account().id().get());
+
+  public static final IndexedField<AccountState, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
+
+  public static final IndexedField<AccountState, String> ID_STR_FIELD =
+      IndexedField.<AccountState>stringBuilder("IdStr")
+          .stored()
+          .required()
+          .build(a -> String.valueOf(a.account().id().get()));
+
+  public static final IndexedField<AccountState, String>.SearchSpec ID_STR_FIELD_SPEC =
+      ID_STR_FIELD.exact("id_str");
 
   /**
    * External IDs.
@@ -55,9 +68,13 @@
    * <p>This field includes secondary emails. Use this field only if the current user is allowed to
    * see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
    */
-  public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
-      exact("external_id")
-          .buildRepeatable(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+  public static final IndexedField<AccountState, Iterable<String>> EXTERNAL_ID_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("ExternalId")
+          .required()
+          .build(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      EXTERNAL_ID_FIELD_SPEC = EXTERNAL_ID_FIELD.exact("external_id");
 
   /**
    * Fuzzy prefix match on name and email parts.
@@ -66,37 +83,57 @@
    * is allowed to see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT}
    * capability).
    *
-   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL} if the current user can't see
+   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL_SPEC} if the current user can't see
    * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
-      prefix("name")
-          .buildRepeatable(
-              a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+  public static final IndexedField<AccountState, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("FullNameAndAllEmailsParts")
+          .description("Full name, all linked emails and their parts (split at special characters)")
+          .required()
+          .build(a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name");
 
   /**
    * Fuzzy prefix match on name and preferred email parts. Parts of secondary emails are not
    * included.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART_NO_SECONDARY_EMAIL =
-      prefix("name2")
-          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
+  public static final IndexedField<AccountState, Iterable<String>>
+      NAME_PART_NO_SECONDARY_EMAIL_FIELD =
+          IndexedField.<AccountState>iterableStringBuilder("FullNameAndPreferredEmailParts")
+              .description(
+                  "Full name, preferred emails and its parts (split at special characters)")
+              .required()
+              .build(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
 
-  public static final FieldDef<AccountState, String> FULL_NAME =
-      exact("full_name").build(a -> a.account().fullName());
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      NAME_PART_NO_SECONDARY_EMAIL_SPEC = NAME_PART_NO_SECONDARY_EMAIL_FIELD.prefix("name2");
 
-  public static final FieldDef<AccountState, String> ACTIVE =
-      exact("inactive").build(a -> a.account().isActive() ? "1" : "0");
+  public static final IndexedField<AccountState, String> FULL_NAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("FullName").build(a -> a.account().fullName());
 
+  public static final IndexedField<AccountState, String>.SearchSpec FULL_NAME_SPEC =
+      FULL_NAME_FIELD.exact("full_name");
+
+  public static final IndexedField<AccountState, String> ACTIVE_FIELD =
+      IndexedField.<AccountState>stringBuilder("Active")
+          .required()
+          .build(a -> a.account().isActive() ? "1" : "0");
+
+  public static final IndexedField<AccountState, String>.SearchSpec ACTIVE_FIELD_SPEC =
+      ACTIVE_FIELD.exact("inactive");
   /**
    * All emails (preferred email + secondary emails). Use this field only if the current user is
    * allowed to see secondary emails (requires the 'Modify Account' capability).
    *
-   * <p>Use the {@link AccountField#PREFERRED_EMAIL} if the current user can't see secondary emails.
+   * <p>Use the {@link AccountField#PREFERRED_EMAIL_LOWER_CASE_SPEC} if the current user can't see
+   * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> EMAIL =
-      prefix("email")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<String>> EMAIL_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("Email")
+          .required()
+          .build(
               a ->
                   FluentIterable.from(a.externalIds())
                       .transform(ExternalId::email)
@@ -105,41 +142,66 @@
                       .transform(String::toLowerCase)
                       .toSet());
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
-      prefix("preferredemail")
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec EMAIL_SPEC =
+      EMAIL_FIELD.prefix("email");
+
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_LOWER_CASE_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmailLowerCase")
           .build(
               a -> {
                 String preferredEmail = a.account().preferredEmail();
                 return preferredEmail != null ? preferredEmail.toLowerCase() : null;
               });
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
-      exact("preferredemail_exact").build(a -> a.account().preferredEmail());
+  public static final IndexedField<AccountState, String>.SearchSpec
+      PREFERRED_EMAIL_LOWER_CASE_SPEC = PREFERRED_EMAIL_LOWER_CASE_FIELD.prefix("preferredemail");
 
-  public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> a.account().registeredOn());
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_EXACT_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmail")
+          .build(a -> a.account().preferredEmail());
 
-  public static final FieldDef<AccountState, String> USERNAME =
-      exact("username").build(a -> a.userName().map(String::toLowerCase).orElse(""));
+  public static final IndexedField<AccountState, String>.SearchSpec PREFERRED_EMAIL_EXACT_SPEC =
+      PREFERRED_EMAIL_EXACT_FIELD.exact("preferredemail_exact");
 
-  public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
-      exact("watchedproject")
-          .buildRepeatable(
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<AccountState, Timestamp> REGISTERED_FIELD =
+      IndexedField.<AccountState>timestampBuilder("Registered")
+          .required()
+          .build(a -> Timestamp.from(a.account().registeredOn()));
+
+  public static final IndexedField<AccountState, Timestamp>.SearchSpec REGISTERED_SPEC =
+      REGISTERED_FIELD.timestamp("registered");
+
+  public static final IndexedField<AccountState, String> USERNAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("Username")
+          .build(a -> a.userName().map(String::toLowerCase).orElse(""));
+
+  public static final IndexedField<AccountState, String>.SearchSpec USERNAME_SPEC =
+      USERNAME_FIELD.exact("username");
+
+  public static final IndexedField<AccountState, Iterable<String>> WATCHED_PROJECT_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("WatchedProject")
+          .build(
               a ->
                   FluentIterable.from(a.projectWatches().keySet())
                       .transform(k -> k.project().get())
                       .toSet());
 
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec WATCHED_PROJECT_SPEC =
+      WATCHED_PROJECT_FIELD.exact("watchedproject");
+
   /**
    * All values of all refs that were used in the course of indexing this document, except the
    * refs/meta/external-ids notes branch which is handled specially (see {@link
-   * #EXTERNAL_ID_STATE}).
+   * #EXTERNAL_ID_STATE_SPEC}).
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("RefState")
+          .stored()
+          .required()
+          .build(
               a -> {
                 if (a.account().metaId() == null) {
                   return ImmutableList.of();
@@ -156,21 +218,29 @@
                         .toByteArray(new AllUsersName(AllUsersNameProvider.DEFAULT)));
               });
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
+
   /**
    * All note values of all external IDs that were used in the course of indexing this document.
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
    * note blob]}, or with other words {@code [note ID]:[note data ID]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
-      storedOnly("external_id_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("ExternalIdState")
+          .stored()
+          .required()
+          .build(
               a ->
                   a.externalIds().stream()
                       .filter(e -> e.blobId() != null)
                       .map(ExternalId::toByteArray)
                       .collect(toSet()));
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec
+      EXTERNAL_ID_STATE_SPEC = EXTERNAL_ID_STATE_FIELD.storedOnly("external_id_state");
+
   private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
     String fullName = a.account().fullName();
     Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
diff --git a/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
index ca7264c..66b85af 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.account.AccountPredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit accounts. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@
   default Predicate<AccountState> keyPredicate(Account.Id id) {
     return AccountPredicates.id(getSchema(), id);
   }
+
+  Function<AccountState, Account.Id> ENTITY_TO_KEY = (a) -> a.account().id();
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 8b95f7b..94dfbf1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -58,19 +58,19 @@
   public void validateMaxTermsInQuery(Predicate<AccountState> predicate)
       throws QueryParseException {
     MutableInteger leafTerms = new MutableInteger();
-    validateMaxTermsInQuery(predicate, leafTerms);
+    countLeafTerms(predicate, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
   }
 
-  private void validateMaxTermsInQuery(Predicate<AccountState> predicate, MutableInteger leafTerms)
-      throws TooManyTermsInQueryException {
-    if (!(predicate instanceof IndexPredicate)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+  private void countLeafTerms(Predicate<AccountState> predicate, MutableInteger leafTerms) {
+    if (predicate instanceof IndexPredicate) {
+      ++leafTerms.value;
     }
 
     for (Predicate<AccountState> childPredicate : predicate.getChildren()) {
-      validateMaxTermsInQuery(childPredicate, leafTerms);
+      countLeafTerms(childPredicate, leafTerms);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 5de3ba4..31fbf36 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -16,35 +16,55 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.account.AccountState;
 
-/** Definition of account index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of account index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
+
   @Deprecated
-  static final Schema<AccountState> V4 =
+  static final Schema<AccountState> V8 =
       schema(
-          AccountField.ACTIVE,
-          AccountField.EMAIL,
-          AccountField.EXTERNAL_ID,
-          AccountField.FULL_NAME,
-          AccountField.ID,
-          AccountField.NAME_PART,
-          AccountField.REGISTERED,
-          AccountField.USERNAME,
-          AccountField.WATCHED_PROJECT);
-
-  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
-
-  @Deprecated
-  static final Schema<AccountState> V6 =
-      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
-
-  @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);
+          /* version= */ 8,
+          ImmutableList.of(),
+          ImmutableList.of(
+              AccountField.ID_FIELD,
+              AccountField.ACTIVE_FIELD,
+              AccountField.EMAIL_FIELD,
+              AccountField.EXTERNAL_ID_FIELD,
+              AccountField.EXTERNAL_ID_STATE_FIELD,
+              AccountField.FULL_NAME_FIELD,
+              AccountField.NAME_PART_FIELD,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_FIELD,
+              AccountField.PREFERRED_EMAIL_EXACT_FIELD,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_FIELD,
+              AccountField.REF_STATE_FIELD,
+              AccountField.REGISTERED_FIELD,
+              AccountField.USERNAME_FIELD,
+              AccountField.WATCHED_PROJECT_FIELD),
+          ImmutableList.<IndexedField<AccountState, ?>.SearchSpec>of(
+              AccountField.ID_FIELD_SPEC,
+              AccountField.ACTIVE_FIELD_SPEC,
+              AccountField.EMAIL_SPEC,
+              AccountField.EXTERNAL_ID_FIELD_SPEC,
+              AccountField.EXTERNAL_ID_STATE_SPEC,
+              AccountField.FULL_NAME_SPEC,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
+              AccountField.NAME_PART_SPEC,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
+              AccountField.PREFERRED_EMAIL_EXACT_SPEC,
+              AccountField.REF_STATE_SPEC,
+              AccountField.REGISTERED_SPEC,
+              AccountField.USERNAME_SPEC,
+              AccountField.WATCHED_PROJECT_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<AccountState> V9 = schema(V8);
@@ -55,14 +75,19 @@
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
   // document id type is replaced with string document id type.
+  @Deprecated
   static final Schema<AccountState> V11 =
       new Schema.Builder<AccountState>()
           .add(V10)
-          .remove(AccountField.ID)
-          .add(AccountField.ID_STR)
-          .legacyNumericFields(false)
+          .remove(AccountField.ID_FIELD_SPEC)
+          .remove(AccountField.ID_FIELD)
+          .addIndexedFields(AccountField.ID_STR_FIELD)
+          .addSearchSpecs(AccountField.ID_STR_FIELD_SPEC)
           .build();
 
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<AccountState> V12 = schema(V11);
+
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 63889b7..1f48e35 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -50,15 +51,18 @@
   private final ListeningExecutorService executor;
   private final Accounts accounts;
   private final AccountCache accountCache;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   @Inject
   AllAccountsIndexer(
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       Accounts accounts,
-      AccountCache accountCache) {
+      AccountCache accountCache,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.accounts = accounts;
     this.accountCache = accountCache;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @Override
@@ -92,7 +96,11 @@
                 try {
                   Optional<AccountState> a = accountCache.get(id);
                   if (a.isPresent()) {
-                    index.replace(a.get());
+                    if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+                      index.insert(a.get());
+                    } else {
+                      index.replace(a.get());
+                    }
                   } else {
                     index.delete(id);
                   }
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 50fdcde..699dfbe 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -58,15 +58,15 @@
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
-          AccountField.ID.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   public static final ImmutableSet<String> FIELDS2 =
       ImmutableSet.of(
-          AccountField.ID_STR.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_STR_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   private final AccountIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -94,13 +94,13 @@
       // No index; caller couldn't do anything if it is stale.
       return StalenessCheckResult.notStale();
     }
-    if (!i.getSchema().hasField(AccountField.REF_STATE)
-        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
+    if (!i.getSchema().hasField(AccountField.REF_STATE_SPEC)
+        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE_SPEC)) {
       // Index version not new enough for this check.
       return StalenessCheckResult.notStale();
     }
 
-    boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
+    boolean useLegacyNumericFields = i.getSchema().hasField(AccountField.ID_FIELD_SPEC);
     ImmutableSet<String> fields = useLegacyNumericFields ? FIELDS : FIELDS2;
     Optional<FieldBundle> result =
         i.getRaw(
@@ -121,8 +121,9 @@
       }
     }
 
-    for (Map.Entry<Project.NameKey, RefState> e :
-        RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
+    Iterable<byte[]> refStates =
+        result.get().<Iterable<byte[]>>getValue(AccountField.REF_STATE_SPEC);
+    for (Map.Entry<Project.NameKey, RefState> e : RefState.parseStates(refStates).entries()) {
       // 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 =
@@ -137,8 +138,10 @@
     }
 
     Set<ExternalId> extIds = externalIds.byAccount(id);
+
     ListMultimap<ObjectId, ObjectId> extIdStates =
-        parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
+        parseExternalIdStates(
+            result.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC));
     if (extIdStates.size() != extIds.size()) {
       return StalenessCheckResult.stale(
           "External IDs of the account were modified since the account was indexed. (%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 1b51703..340d956 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -14,45 +14,44 @@
 
 package com.google.gerrit.server.index.change;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.util.concurrent.Futures.successfulAsList;
 import static com.google.common.util.concurrent.Futures.transform;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableSortedSet;
 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.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.SiteIndexer;
 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.git.MultiProgressMonitor.TaskKind;
+import com.google.gerrit.server.git.MultiProgressMonitor.VolatileTask;
 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.notedb.ChangeNotes.Factory.ScanResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
 import java.util.concurrent.Callable;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
 
 /**
  * Implementation that can index all changes on a host or within a project. Used by Gerrit's
@@ -61,9 +60,21 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private MultiProgressMonitor mpm;
+  private VolatileTask doneTask;
+  private Task failedTask;
   private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
   private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
+
+  private static class ProjectsCollectionFailure extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public ProjectsCollectionFailure(String message) {
+      super(message);
+    }
+  }
+
   private final ChangeData.Factory changeDataFactory;
   private final GitRepositoryManager repoManager;
   private final ListeningExecutorService executor;
@@ -89,127 +100,53 @@
     this.projectCache = projectCache;
   }
 
-  private static class ProjectSlice {
-    private final Project.NameKey name;
-    private final int slice;
-    private final int slices;
+  @AutoValue
+  public abstract static class ProjectSlice {
+    public abstract Project.NameKey name();
 
-    ProjectSlice(Project.NameKey name, int slice, int slices) {
-      this.name = name;
-      this.slice = slice;
-      this.slices = slices;
+    public abstract int slice();
+
+    public abstract int slices();
+
+    public abstract ScanResult scanResult();
+
+    private static ProjectSlice create(Project.NameKey name, int slice, int slices, ScanResult sr) {
+      return new AutoValue_AllChangesIndexer_ProjectSlice(name, slice, slices, sr);
     }
 
-    public Project.NameKey getName() {
-      return name;
-    }
-
-    public int getSlice() {
-      return slice;
-    }
-
-    public int getSlices() {
-      return slices;
+    private static ProjectSlice oneSlice(Project.NameKey name, ScanResult sr) {
+      return new AutoValue_AllChangesIndexer_ProjectSlice(name, 0, 1, sr);
     }
   }
 
   @Override
   public Result indexAll(ChangeIndex index) {
-    ProgressMonitor pm = new TextProgressMonitor();
-    pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-    List<ProjectSlice> projectSlices = new ArrayList<>();
-    int changeCount = 0;
+    // The simplest approach to distribute indexing would be to let each thread grab a project
+    // and index it fully. But if a site has one big project and 100s of small projects, then
+    // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
+    // projects have been reindexed, and only the thread that reindexes the big project is
+    // still working. The other threads would idle. Reindexing the big project on a single
+    // thread becomes the critical path. Bringing in more CPUs would not speed up things.
+    //
+    // To avoid such situations, we split big repos into smaller parts and let
+    // the thread pool index these smaller parts. This splitting introduces an overhead in the
+    // workload setup and there might be additional slow-downs from multiple threads
+    // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
+    // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
+    // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
+    // in 2020.
+
     Stopwatch sw = Stopwatch.createStarted();
-    int projectsFailed = 0;
-    for (Project.NameKey name : projectCache.all()) {
-      try (Repository repo = repoManager.openRepository(name)) {
-        // The simplest approach to distribute indexing would be to let each thread grab a project
-        // and index it fully. But if a site has one big project and 100s of small projects, then
-        // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
-        // projects have been reindexed, and only the thread that reindexes the big project is
-        // still working. The other threads would idle. Reindexing the big project on a single
-        // thread becomes the critical path. Bringing in more CPUs would not speed up things.
-        //
-        // To avoid such situations, we split big repos into smaller parts and let
-        // the thread pool index these smaller parts. This splitting introduces an overhead in the
-        // workload setup and there might be additional slow-downs from multiple threads
-        // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
-        // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
-        // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
-        // in 2020.
-        int size = estimateSize(repo);
-        changeCount += size;
-        int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
-        if (slices > 1) {
-          verboseWriter.println("Submitting " + name + " for indexing in " + slices + " slices");
-        }
-        for (int slice = 0; slice < slices; slice++) {
-          projectSlices.add(new ProjectSlice(name, slice, slices));
-        }
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Error collecting project %s", name);
-        projectsFailed++;
-        if (projectsFailed > projectCache.all().size() / 2) {
-          logger.atSevere().log("Over 50%% of the projects could not be collected: aborted");
-          return Result.create(sw, false, 0, 0);
-        }
-      }
-      pm.update(1);
-    }
-    pm.endTask();
-    setTotalWork(changeCount);
-
-    // projectSlices are currently grouped by projects. First all slices for project1, followed
-    // by all slices for project2, and so on. As workers pick tasks sequentially, multiple threads
-    // would typically work concurrently on different slices of the same project. While this is not
-    // a big issue, shuffling the list beforehand helps with ungrouping the project slices, so
-    // different slices are less likely to be worked on concurrently.
-    // This shuffling gave a 6% runtime reduction for Wikimedia's Gerrit in 2020.
-    Collections.shuffle(projectSlices);
-    return indexAll(index, projectSlices);
-  }
-
-  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.
-    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, List<ProjectSlice> projectSlices) {
-    Stopwatch sw = Stopwatch.createStarted();
-    MultiProgressMonitor mpm =
-        multiProgressMonitorFactory.create(progressOut, TaskKind.INDEXING, "Reindexing changes");
-    Task projTask = mpm.beginSubTask("project-slices", projectSlices.size());
-    checkState(totalWork >= 0);
-    Task doneTask = mpm.beginSubTask(null, totalWork);
-    Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
-
-    List<ListenableFuture<?>> futures = new ArrayList<>();
     AtomicBoolean ok = new AtomicBoolean(true);
-
-    for (ProjectSlice projectSlice : projectSlices) {
-      Project.NameKey name = projectSlice.getName();
-      int slice = projectSlice.getSlice();
-      int slices = projectSlice.getSlices();
-      ListenableFuture<?> future =
-          executor.submit(
-              reindexProject(
-                  indexerFactory.create(executor, index),
-                  name,
-                  slice,
-                  slices,
-                  doneTask,
-                  failedTask));
-      String description = "project " + name + " (" + slice + "/" + slices + ")";
-      addErrorListener(future, description, projTask, ok);
-      futures.add(future);
+    mpm = multiProgressMonitorFactory.create(progressOut, TaskKind.INDEXING, "Reindexing changes");
+    doneTask = mpm.beginVolatileSubTask("changes");
+    failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+    List<ListenableFuture<?>> futures;
+    try {
+      futures = new SliceScheduler(index, ok).schedule();
+    } catch (ProjectsCollectionFailure e) {
+      logger.atSevere().log("%s", e.getMessage());
+      return Result.create(sw, false, 0, 0);
     }
 
     try {
@@ -237,64 +174,64 @@
           "Failed %s/%s changes (%s%%); not marking new index as ready",
           nFailed, nTotal, Math.round(pctFailed));
       ok.set(false);
+    } else if (nFailed > 0) {
+      logger.atWarning().log("Failed %s/%s changes", nFailed, nTotal);
     }
     return Result.create(sw, ok.get(), nDone, nFailed);
   }
 
+  @Nullable
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
-    return reindexProject(indexer, project, 0, 1, done, failed);
+    try (Repository repo = repoManager.openRepository(project)) {
+      return reindexProjectSlice(
+          indexer,
+          ProjectSlice.oneSlice(project, ChangeNotes.Factory.scanChangeIds(repo)),
+          done,
+          failed);
+    } catch (IOException e) {
+      logger.atSevere().log("%s", e.getMessage());
+      return null;
+    }
   }
 
-  public Callable<Void> reindexProject(
-      ChangeIndexer indexer,
-      Project.NameKey project,
-      int slice,
-      int slices,
-      Task done,
-      Task failed) {
-    return new ProjectIndexer(indexer, project, slice, slices, done, failed);
+  public Callable<Void> reindexProjectSlice(
+      ChangeIndexer indexer, ProjectSlice projectSlice, Task done, Task failed) {
+    return new ProjectSliceIndexer(indexer, projectSlice, done, failed);
   }
 
-  private class ProjectIndexer implements Callable<Void> {
+  private class ProjectSliceIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
-    private final Project.NameKey project;
-    private final int slice;
-    private final int slices;
+    private final ProjectSlice projectSlice;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
 
-    private ProjectIndexer(
+    private ProjectSliceIndexer(
         ChangeIndexer indexer,
-        Project.NameKey project,
-        int slice,
-        int slices,
+        ProjectSlice projectSlice,
         ProgressMonitor done,
         ProgressMonitor failed) {
       this.indexer = indexer;
-      this.project = project;
-      this.slice = slice;
-      this.slices = slices;
+      this.projectSlice = projectSlice;
       this.done = done;
       this.failed = failed;
     }
 
     @Override
     public Void call() throws Exception {
-      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, project, id -> (id.get() % slices) == slice).forEach(r -> index(r));
-      } catch (RepositoryNotFoundException rnfe) {
-        logger.atSevere().log(rnfe.getMessage());
-      } finally {
-        OnlineReindexMode.end();
-      }
+      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(
+              projectSlice.scanResult(),
+              projectSlice.name(),
+              id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
+          .forEach(r -> index(r));
+      OnlineReindexMode.end();
       return null;
     }
 
@@ -321,7 +258,7 @@
         this.failed.update(1);
       }
 
-      logger.atWarning().withCause(e).log(error);
+      logger.atWarning().withCause(e).log("%s", error);
       verboseWriter.println(error);
     }
 
@@ -331,7 +268,103 @@
 
     @Override
     public String toString() {
-      return "Index all changes of project " + project.get();
+      return "Index project slice " + projectSlice;
+    }
+  }
+
+  private class SliceScheduler {
+    final ChangeIndex index;
+    final AtomicBoolean ok;
+    final AtomicInteger changeCount = new AtomicInteger(0);
+    final AtomicInteger projectsFailed = new AtomicInteger(0);
+    final List<ListenableFuture<?>> sliceIndexerFutures = new ArrayList<>();
+    final List<ListenableFuture<?>> sliceCreationFutures = new ArrayList<>();
+    VolatileTask projTask = mpm.beginVolatileSubTask("project-slices");
+    Task slicingProjects;
+
+    public SliceScheduler(ChangeIndex index, AtomicBoolean ok) {
+      this.index = index;
+      this.ok = ok;
+    }
+
+    private List<ListenableFuture<?>> schedule() throws ProjectsCollectionFailure {
+      ImmutableSortedSet<Project.NameKey> projects = projectCache.all();
+      int projectCount = projects.size();
+      slicingProjects = mpm.beginSubTask("Slicing projects", projectCount);
+      for (Project.NameKey name : projects) {
+        sliceCreationFutures.add(executor.submit(new ProjectSliceCreator(name)));
+      }
+
+      try {
+        mpm.waitForNonFinalTask(
+            transform(
+                successfulAsList(sliceCreationFutures),
+                x -> {
+                  projTask.finalizeTotal();
+                  doneTask.finalizeTotal();
+                  return null;
+                },
+                directExecutor()));
+      } catch (UncheckedExecutionException e) {
+        logger.atSevere().withCause(e).log("Error project slice creation");
+        ok.set(false);
+      }
+
+      if (projectsFailed.get() > projectCount / 2) {
+        throw new ProjectsCollectionFailure(
+            "Over 50%% of the projects could not be collected: aborted");
+      }
+
+      slicingProjects.endTask();
+      setTotalWork(changeCount.get());
+
+      return sliceIndexerFutures;
+    }
+
+    private class ProjectSliceCreator implements Callable<Void> {
+      final Project.NameKey name;
+
+      public ProjectSliceCreator(Project.NameKey name) {
+        this.name = name;
+      }
+
+      @Override
+      public Void call() throws IOException {
+        try (Repository repo = repoManager.openRepository(name)) {
+          ScanResult sr = ChangeNotes.Factory.scanChangeIds(repo);
+          int size = sr.all().size();
+          if (size > 0) {
+            changeCount.addAndGet(size);
+            int slices = 1 + (size - 1) / PROJECT_SLICE_MAX_REFS;
+            if (slices > 1) {
+              verboseWriter.println(
+                  "Submitting " + name + " for indexing in " + slices + " slices");
+            }
+
+            doneTask.updateTotal(size);
+            projTask.updateTotal(slices);
+
+            for (int slice = 0; slice < slices; slice++) {
+              ProjectSlice projectSlice = ProjectSlice.create(name, slice, slices, sr);
+              ListenableFuture<?> future =
+                  executor.submit(
+                      reindexProjectSlice(
+                          indexerFactory.create(executor, index),
+                          projectSlice,
+                          doneTask,
+                          failedTask));
+              String description = "project " + name + " (" + slice + "/" + slices + ")";
+              addErrorListener(future, description, projTask, ok);
+              sliceIndexerFutures.add(future);
+            }
+          }
+        } catch (IOException e) {
+          logger.atSevere().withCause(e).log("Error collecting project %s", name);
+          projectsFailed.incrementAndGet();
+        }
+        slicingProjects.update(1);
+        return null;
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 2cdb7c8..1c89566f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -18,13 +18,6 @@
 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.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.intRange;
-import static com.google.gerrit.index.FieldDef.integer;
-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.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
@@ -44,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
+import com.google.common.reflect.TypeToken;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -56,18 +50,21 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.SchemaFieldDefs;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.proto.Protos;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -82,6 +79,7 @@
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -91,6 +89,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -104,6 +103,9 @@
  *
  * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
  * unambiguous derived field names containing other characters.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
  */
 public class ChangeField {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -112,64 +114,127 @@
 
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
 
+  /**
+   * To avoid the non-google dependency on org.apache.lucene.index.IndexWriter.MAX_TERM_LENGTH it is
+   * redefined here.
+   */
+  public static final int MAX_TERM_LENGTH = (1 << 15) - 2;
+
   // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      integer("legacy_id").stored().build(cd -> cd.getId().get());
+  public static final IndexedField<ChangeData, String> NUMERIC_ID_STR_FIELD =
+      IndexedField.<ChangeData>stringBuilder("NumericIdStr")
+          .stored()
+          .required()
+          // The numeric change id is integer in string form
+          .size(10)
+          .build(cd -> String.valueOf(cd.getId().get()));
 
-  public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
-      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getId().get()));
+  public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
+      NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
 
   /** Newer style Change-Id key. */
-  public static final FieldDef<ChangeData, String> ID =
-      prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
+  public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
+      IndexedField.<ChangeData>stringBuilder("ChangeId")
+          .required()
+          // The new style key is in form Isha1
+          .size(41)
+          .build(changeGetter(c -> c.getKey().get()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec CHANGE_ID_SPEC =
+      CHANGE_ID_FIELD.prefix(ChangeQueryBuilder.FIELD_CHANGE_ID);
 
   /** Change status string, in the same format as {@code status:}. */
-  public static final FieldDef<ChangeData, String> STATUS =
-      exact(ChangeQueryBuilder.FIELD_STATUS)
+  public static final IndexedField<ChangeData, String> STATUS_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Status")
+          .required()
+          .size(20)
           .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec STATUS_SPEC =
+      STATUS_FIELD.exact(ChangeQueryBuilder.FIELD_STATUS);
+
   /** Project containing the change. */
-  public static final FieldDef<ChangeData, String> PROJECT =
-      exact(ChangeQueryBuilder.FIELD_PROJECT)
+  public static final IndexedField<ChangeData, String> PROJECT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Project")
+          .required()
           .stored()
+          .size(200)
           .build(changeGetter(c -> c.getProject().get()));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec PROJECT_SPEC =
+      PROJECT_FIELD.exact(ChangeQueryBuilder.FIELD_PROJECT);
+
   /** Project containing the change, as a prefix field. */
-  public static final FieldDef<ChangeData, String> PROJECTS =
-      prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get()));
+  public static final IndexedField<ChangeData, String>.SearchSpec PROJECTS_SPEC =
+      PROJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PROJECTS);
 
   /** Reference (aka branch) the change will submit onto. */
-  public static final FieldDef<ChangeData, String> REF =
-      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().branch()));
+  public static final IndexedField<ChangeData, String> REF_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Ref")
+          .required()
+          .size(300)
+          .build(changeGetter(c -> c.getDest().branch()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec REF_SPEC =
+      REF_FIELD.exact(ChangeQueryBuilder.FIELD_REF);
 
   /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> EXACT_TOPIC =
-      exact("topic4").build(ChangeField::getTopic);
+  public static final IndexedField<ChangeData, String> TOPIC_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Topic").size(500).build(ChangeField::getTopic);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec EXACT_TOPIC =
+      TOPIC_FIELD.exact("topic4");
 
   /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
-      fullText("topic5").build(ChangeField::getTopic);
+  public static final IndexedField<ChangeData, String>.SearchSpec FUZZY_TOPIC =
+      TOPIC_FIELD.fullText("topic5");
 
-  /** Submission id assigned by MergeOp. */
-  public static final FieldDef<ChangeData, String> SUBMISSIONID =
-      exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
+  /** Topic, a short annotation on the branch. */
+  public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_TOPIC =
+      TOPIC_FIELD.prefix("topic6");
+
+  /** {@link com.google.gerrit.entities.SubmissionId} assigned by MergeOp. */
+  public static final IndexedField<ChangeData, String> SUBMISSIONID_FIELD =
+      IndexedField.<ChangeData>stringBuilder("SubmissionId")
+          .size(500)
+          .build(changeGetter(Change::getSubmissionId));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec SUBMISSIONID_SPEC =
+      SUBMISSIONID_FIELD.exact(ChangeQueryBuilder.FIELD_SUBMISSIONID);
 
   /** Last update time since January 1, 1970. */
-  public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<ChangeData, Timestamp> UPDATED_FIELD =
+      IndexedField.<ChangeData>timestampBuilder("LastUpdated")
+          .stored()
+          .build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
+
+  public static final IndexedField<ChangeData, Timestamp>.SearchSpec UPDATED_SPEC =
+      UPDATED_FIELD.timestamp("updated2");
 
   /** When this change was merged, time since January 1, 1970. */
-  public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
-      timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<ChangeData, Timestamp> MERGED_ON_FIELD =
+      IndexedField.<ChangeData>timestampBuilder("MergedOn")
           .stored()
-          .build(cd -> cd.getMergedOn().orElse(null), (cd, field) -> cd.setMergedOn(field));
+          .build(
+              cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
+              (cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
+
+  public static final IndexedField<ChangeData, Timestamp>.SearchSpec MERGED_ON_SPEC =
+      MERGED_ON_FIELD.timestamp(ChangeQueryBuilder.FIELD_MERGED_ON);
 
   /** List of full file paths modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> PATH =
+  public static final IndexedField<ChangeData, Iterable<String>> PATH_FIELD =
       // Named for backwards compatibility.
-      exact(ChangeQueryBuilder.FIELD_FILE)
-          .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+      IndexedField.<ChangeData>iterableStringBuilder("File")
+          .build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PATH_SPEC =
+      PATH_FIELD
+          // Named for backwards compatibility.
+          .exact(ChangeQueryBuilder.FIELD_FILE);
 
   public static Set<String> getFileParts(ChangeData cd) {
     List<String> paths = cd.currentFilePaths();
@@ -185,19 +250,27 @@
   }
 
   /** Hashtags tied to a change */
-  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
-      exact(ChangeQueryBuilder.FIELD_HASHTAG)
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
+          .size(200)
+          .build(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec HASHTAG_SPEC =
+      HASHTAG_FIELD.exact(ChangeQueryBuilder.FIELD_HASHTAG);
 
   /** Hashtags as fulltext field for in-string search. */
-  public static final FieldDef<ChangeData, Iterable<String>> FUZZY_HASHTAG =
-      fullText("hashtag2")
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FUZZY_HASHTAG =
+      HASHTAG_FIELD.fullText("hashtag2");
+
+  /** Hashtags as prefix field for in-string search. */
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PREFIX_HASHTAG =
+      HASHTAG_FIELD.prefix("hashtag3");
 
   /** Hashtags with original case. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
-      storedOnly("_hashtag")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("HashtagCaseAware")
+          .stored()
+          .build(
               cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
               (cd, field) ->
                   cd.setHashtags(
@@ -205,13 +278,24 @@
                           .map(f -> new String(f, UTF_8))
                           .collect(toImmutableSet())));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      HASHTAG_CASE_AWARE_SPEC = HASHTAG_CASE_AWARE_FIELD.storedOnly("_hashtag");
+
   /** Components of each file path modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
-      exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
+  public static final IndexedField<ChangeData, Iterable<String>> FILE_PART_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("FilePart").build(ChangeField::getFileParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FILE_PART_SPEC =
+      FILE_PART_FIELD.exact(ChangeQueryBuilder.FIELD_FILEPART);
 
   /** 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 final IndexedField<ChangeData, Iterable<String>> EXTENSION_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Extension")
+          .size(100)
+          .build(ChangeField::getExtensions);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXTENSION_SPEC =
+      EXTENSION_FIELD.exact(ChangeQueryBuilder.FIELD_EXTENSION);
 
   public static Set<String> getExtensions(ChangeData cd) {
     return extensions(cd).collect(toSet());
@@ -221,8 +305,12 @@
    * 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 final IndexedField<ChangeData, String> ONLY_EXTENSIONS_FIELD =
+      IndexedField.<ChangeData>stringBuilder("OnlyExtensions")
+          .build(ChangeField::getAllExtensionsAsList);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec ONLY_EXTENSIONS_SPEC =
+      ONLY_EXTENSIONS_FIELD.exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS);
 
   public static String getAllExtensionsAsList(ChangeData cd) {
     return extensions(cd).distinct().sorted().collect(joining(","));
@@ -246,8 +334,11 @@
   }
 
   /** 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 final IndexedField<ChangeData, Iterable<String>> FOOTER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Footer").build(ChangeField::getFooters);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_SPEC =
+      FOOTER_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER);
 
   public static Set<String> getFooters(ChangeData cd) {
     return cd.commitFooters().stream()
@@ -255,9 +346,24 @@
         .collect(toSet());
   }
 
+  /** Footers from the commit message of the current patch set. */
+  public static final IndexedField<ChangeData, Iterable<String>> FOOTER_NAME_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("FooterName")
+          .build(ChangeField::getFootersNames);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_NAME =
+      FOOTER_NAME_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER_NAME);
+
+  public static Set<String> getFootersNames(ChangeData cd) {
+    return cd.commitFooters().stream().map(f -> f.getKey()).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 final IndexedField<ChangeData, Iterable<String>> DIRECTORY_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("DirField").build(ChangeField::getDirectories);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec DIRECTORY_SPEC =
+      DIRECTORY_FIELD.exact(ChangeQueryBuilder.FIELD_DIRECTORY);
 
   public static Set<String> getDirectories(ChangeData cd) {
     List<String> paths = cd.currentFilePaths();
@@ -292,31 +398,47 @@
   }
 
   /** Owner/creator of the change. */
-  public static final FieldDef<ChangeData, Integer> OWNER =
-      integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
+  public static final IndexedField<ChangeData, Integer> OWNER_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Owner")
+          .required()
+          .build(changeGetter(c -> c.getOwner().get()));
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec OWNER_SPEC =
+      OWNER_FIELD.integer(ChangeQueryBuilder.FIELD_OWNER);
 
   /** Uploader of the latest patch set. */
-  public static final FieldDef<ChangeData, Integer> UPLOADER =
-      integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+  public static final IndexedField<ChangeData, Integer> UPLOADER_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Uploader")
+          .required()
+          .build(cd -> cd.currentPatchSet().uploader().get());
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec UPLOADER_SPEC =
+      UPLOADER_FIELD.integer(ChangeQueryBuilder.FIELD_UPLOADER);
 
   /** References the source change number that this change was cherry-picked from. */
-  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
-      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
+  public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_CHANGE_FIELD =
+      IndexedField.<ChangeData>integerBuilder("CherryPickOfChange")
           .build(
               cd ->
                   cd.change().getCherryPickOf() != null
                       ? cd.change().getCherryPickOf().changeId().get()
                       : null);
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_CHANGE =
+      CHERRY_PICK_OF_CHANGE_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE);
+
   /** References the source change patch-set that this change was cherry-picked from. */
-  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET =
-      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET)
+  public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET_FIELD =
+      IndexedField.<ChangeData>integerBuilder("CherryPickOfPatchset")
           .build(
               cd ->
                   cd.change().getCherryPickOf() != null
                       ? cd.change().getCherryPickOf().get()
                       : null);
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_PATCHSET =
+      CHERRY_PICK_OF_PATCHSET_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET);
+
   /** This class decouples the internal and API types from storage. */
   private static class StoredAttentionSetEntry {
     final long timestampMillis;
@@ -341,25 +463,35 @@
    * Users included in the attention set of the change. This omits timestamp, reason and possible
    * future fields.
    *
-   * @see #ATTENTION_SET_FULL
+   * @see #ATTENTION_SET_FULL_SPEC
    */
-  public static final FieldDef<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS =
-      integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
-          .buildRepeatable(ChangeField::getAttentionSetUserIds);
+  public static final IndexedField<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("AttentionSetUsers")
+          .build(ChangeField::getAttentionSetUserIds);
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec ATTENTION_SET_USERS =
+      ATTENTION_SET_USERS_FIELD.integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS);
 
   /** Number of changes that contain attention set. */
-  public static final FieldDef<ChangeData, Integer> ATTENTION_SET_USERS_COUNT =
-      intRange(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT)
+  public static final IndexedField<ChangeData, Integer> ATTENTION_SET_USERS_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("AttentionSetUsersCount")
+          .stored()
           .build(cd -> additionsOnly(cd.attentionSet()).size());
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ATTENTION_SET_USERS_COUNT =
+      ATTENTION_SET_USERS_COUNT_FIELD.integerRange(
+          ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT);
+
   /**
    * The full attention set data including timestamp, reason and possible future fields.
    *
    * @see #ATTENTION_SET_USERS
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
-      storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("AttentionSetFull")
+          .stored()
+          .required()
+          .build(
               ChangeField::storedAttentionSet,
               (cd, value) ->
                   parseAttentionSet(
@@ -368,63 +500,107 @@
                           .collect(toImmutableSet()),
                       cd));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      ATTENTION_SET_FULL_SPEC =
+          ATTENTION_SET_FULL_FIELD.storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL);
+
   /** The user assigned to the change. */
-  public static final FieldDef<ChangeData, Integer> ASSIGNEE =
-      integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
+  public static final IndexedField<ChangeData, Integer> ASSIGNEE_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Assignee")
           .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ASSIGNEE_SPEC =
+      ASSIGNEE_FIELD.integer(ChangeQueryBuilder.FIELD_ASSIGNEE);
+
   /** Reviewer(s) associated with the change. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      exact("reviewer2")
+  public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Reviewer")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerFieldValues(cd.reviewers()),
               (cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_SPEC =
+      REVIEWER_FIELD.exact("reviewer2");
+
   /** Reviewer(s) associated with the change that do not have a gerrit account. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
-      exact("reviewer_by_email")
+  public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ReviewerByEmail")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
               (cd, field) ->
                   cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_BY_EMAIL =
+      REVIEWER_BY_EMAIL_FIELD.exact("reviewer_by_email");
+
   /** Reviewer(s) modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
+  public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("PendingReviewer")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerFieldValues(cd.pendingReviewers()),
               (cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PENDING_REVIEWER_SPEC =
+      PENDING_REVIEWER_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER);
+
   /** Reviewer(s) by email modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
+  public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("PendingReviewerByEmail")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
               (cd, field) ->
                   cd.setPendingReviewersByEmail(
                       parseReviewerByEmailFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+      PENDING_REVIEWER_BY_EMAIL =
+          PENDING_REVIEWER_BY_EMAIL_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL);
+
   /** References a change that this change reverts. */
-  public static final FieldDef<ChangeData, Integer> REVERT_OF =
-      integer(ChangeQueryBuilder.FIELD_REVERTOF)
+  public static final IndexedField<ChangeData, Integer> REVERT_OF_FIELD =
+      IndexedField.<ChangeData>integerBuilder("RevertOf")
           .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
 
-  public static final FieldDef<ChangeData, String> IS_PURE_REVERT =
-      fullText(ChangeQueryBuilder.FIELD_PURE_REVERT)
+  public static final IndexedField<ChangeData, Integer>.SearchSpec REVERT_OF =
+      REVERT_OF_FIELD.integer(ChangeQueryBuilder.FIELD_REVERTOF);
+
+  public static final IndexedField<ChangeData, String> IS_PURE_REVERT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("IsPureRevert")
+          .size(1)
           .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec IS_PURE_REVERT_SPEC =
+      IS_PURE_REVERT_FIELD.fullText(ChangeQueryBuilder.FIELD_PURE_REVERT);
+
+  /**
+   * Determines if a change is submittable based on {@link
+   * com.google.gerrit.entities.SubmitRequirement}s.
+   */
+  public static final IndexedField<ChangeData, String> IS_SUBMITTABLE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("IsSubmittable")
+          .size(1)
+          .build(
+              cd ->
+                  // All submit requirements should be fulfilled
+                  cd.submitRequirementsIncludingLegacy().values().stream()
+                          .allMatch(SubmitRequirementResult::fulfilled)
+                      ? "1"
+                      : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec IS_SUBMITTABLE_SPEC =
+      IS_SUBMITTABLE_FIELD.exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE);
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c :
-        reviewers.asTable().cellSet()) {
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Instant> c : reviewers.asTable().cellSet()) {
       String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
-      r.add(v + ',' + c.getValue().getTime());
+      r.add(v + ',' + c.getValue().toEpochMilli());
     }
     return r;
   }
@@ -436,7 +612,7 @@
   @VisibleForTesting
   static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
     List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+    for (Table.Cell<ReviewerStateInternal, Address, Instant> c :
         reviewersByEmail.asTable().cellSet()) {
       String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
@@ -445,7 +621,7 @@
         Address emailOnly = Address.create(c.getColumnKey().email());
         r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
       }
-      r.add(v + ',' + c.getValue().getTime());
+      r.add(v + ',' + c.getValue().toEpochMilli());
     }
     return r;
   }
@@ -455,8 +631,7 @@
   }
 
   public static ReviewerSet parseReviewerFieldValues(Change.Id changeId, Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
-        ImmutableTable.builder();
+    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Instant> b = ImmutableTable.builder();
     for (String v : values) {
 
       int i = v.indexOf(',');
@@ -498,7 +673,7 @@
             "Failed to parse timestamp of reviewer field from change %s: %s", changeId.get(), v);
         continue;
       }
-      Timestamp timestamp = new Timestamp(l);
+      Instant timestamp = Instant.ofEpochMilli(l);
 
       b.put(reviewerState.get(), accountId.get(), timestamp);
     }
@@ -507,7 +682,7 @@
 
   public static ReviewerByEmailSet parseReviewerByEmailFieldValues(
       Change.Id changeId, Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Instant> b = ImmutableTable.builder();
     for (String v : values) {
       int i = v.indexOf(',');
       if (i < 0) {
@@ -551,7 +726,7 @@
             changeId.get(), v);
         continue;
       }
-      Timestamp timestamp = new Timestamp(l);
+      Instant timestamp = Instant.ofEpochMilli(l);
 
       b.put(reviewerState.get(), address, timestamp);
     }
@@ -594,25 +769,37 @@
   }
 
   /** Commit ID of any patch set on the change, using prefix match. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
-      prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
+  public static final IndexedField<ChangeData, Iterable<String>> COMMIT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Commit")
+          .size(40)
+          .required()
+          .build(ChangeField::getRevisions);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMIT_SPEC =
+      COMMIT_FIELD.prefix(ChangeQueryBuilder.FIELD_COMMIT);
 
   /** Commit ID of any patch set on the change, using exact match. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMIT_SPEC =
+      COMMIT_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT);
 
   private static ImmutableSet<String> getRevisions(ChangeData cd) {
     return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
   }
 
   /** Tracking id extracted from a footer. */
-  public static final FieldDef<ChangeData, Iterable<String>> TR =
-      exact(ChangeQueryBuilder.FIELD_TR)
-          .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+  public static final IndexedField<ChangeData, Iterable<String>> TR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("TrackingFooter")
+          .build(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec TR_SPEC =
+      TR_FIELD.exact(ChangeQueryBuilder.FIELD_TR);
 
   /** List of labels on the current patch set including change owner votes. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact("label2").buildRepeatable(cd -> getLabels(cd));
+  public static final IndexedField<ChangeData, Iterable<String>> LABEL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Label").required().build(cd -> getLabels(cd));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec LABEL_SPEC =
+      LABEL_FIELD.exact("label2");
 
   private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
@@ -755,41 +942,90 @@
    * The exact email address, or any part of the author name or email address, in the current patch
    * set.
    */
-  public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
-      fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
+  public static final IndexedField<ChangeData, Iterable<String>> AUTHOR_PARTS_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("AuthorParts")
+          .required()
+          .description(
+              "The exact email address, or any part of the author name or email address, in the current patch set.")
+          .build(ChangeField::getAuthorParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec AUTHOR_PARTS_SPEC =
+      AUTHOR_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_AUTHOR);
 
   /** The exact name, email address and NameEmail of the author. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
-      exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
-          .buildRepeatable(ChangeField::getAuthorNameAndEmail);
+  public static final IndexedField<ChangeData, Iterable<String>> EXACT_AUTHOR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ExactAuthor")
+          .required()
+          .description("The exact name, email address and NameEmail of the author.")
+          .build(ChangeField::getAuthorNameAndEmail);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_AUTHOR_SPEC =
+      EXACT_AUTHOR_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR);
 
   /**
    * The exact email address, or any part of the committer name or email address, in the current
    * patch set.
    */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
-      fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
+  public static final IndexedField<ChangeData, Iterable<String>> COMMITTER_PARTS_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("CommitterParts")
+          .description(
+              "The exact email address, or any part of the committer name or email address, in the current patch set.")
+          .required()
+          .build(ChangeField::getCommitterParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMITTER_PARTS_SPEC =
+      COMMITTER_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMITTER);
 
   /** The exact name, email address, and NameEmail of the committer. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
-          .buildRepeatable(ChangeField::getCommitterNameAndEmail);
+  public static final IndexedField<ChangeData, Iterable<String>> EXACT_COMMITTER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ExactCommiter")
+          .required()
+          .description("The exact name, email address, and NameEmail of the committer.")
+          .build(ChangeField::getCommitterNameAndEmail);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMITTER_SPEC =
+      EXACT_COMMITTER_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER);
 
   /** Serialized change object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, byte[]> CHANGE =
-      storedOnly("_change")
+  private static final TypeToken<Entities.Change> CHANGE_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Entities.Change> CHANGE_FIELD =
+      IndexedField.<ChangeData, Entities.Change>builder("Change", CHANGE_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(ChangeProtoConverter.INSTANCE))
           .build(
-              changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
-              (cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
+              changeGetter(change -> entityToProto(ChangeProtoConverter.INSTANCE, change)),
+              (cd, value) ->
+                  cd.setChange(decodeProtoToEntity(value, ChangeProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Entities.Change>.SearchSpec CHANGE_SPEC =
+      CHANGE_FIELD.storedOnly("_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(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
+  private static final TypeToken<Iterable<Entities.PatchSetApproval>> APPROVAL_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>> APPROVAL_FIELD =
+      IndexedField.<ChangeData, Iterable<Entities.PatchSetApproval>>builder(
+              "Approval", APPROVAL_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(PatchSetApprovalProtoConverter.INSTANCE))
+          .build(
+              cd ->
+                  entitiesToProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
               (cd, field) ->
                   cd.setCurrentApprovals(
-                      decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
+                      decodeProtosToEntities(field, PatchSetApprovalProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>>.SearchSpec
+      APPROVAL_SPEC = APPROVAL_FIELD.storedOnly("_approval");
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
@@ -831,13 +1067,41 @@
   }
 
   /** Commit message of the current patch set. */
-  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
-      fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
+  public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CommitMessage")
+          .required()
+          .build(ChangeData::commitMessage);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE =
+      COMMIT_MESSAGE_FIELD.fullText(ChangeQueryBuilder.FIELD_MESSAGE);
+
+  /** Commit message of the current patch set, used to exactly match the commit message */
+  public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_EXACT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CommitMessageExact")
+          .required()
+          .description(
+              "Same as CommitMessage, but truncated, since supporting such large tokens may be problematic for indexes.")
+          .build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE_EXACT =
+      COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
+
+  /** Subject of the current patch set (aka first line of the commit message). */
+  public static final IndexedField<ChangeData, String> SUBJECT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Subject")
+          .required()
+          .build(changeGetter(Change::getSubject));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec SUBJECT_SPEC =
+      SUBJECT_FIELD.fullText(ChangeQueryBuilder.FIELD_SUBJECT);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_SUBJECT_SPEC =
+      SUBJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
 
   /** Summary or inline comment. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
-      fullText(ChangeQueryBuilder.FIELD_COMMENT)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> COMMENT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Comment")
+          .build(
               cd ->
                   Stream.concat(
                           cd.publishedComments().stream().map(c -> c.message),
@@ -848,22 +1112,35 @@
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMENT_SPEC =
+      COMMENT_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMENT);
+
   /** 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)
+  public static final IndexedField<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("UnresolvedCommentCount")
+          .stored()
           .build(
               ChangeData::unresolvedCommentCount,
               (cd, field) -> cd.setUnresolvedCommentCount(field));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec UNRESOLVED_COMMENT_COUNT_SPEC =
+      UNRESOLVED_COMMENT_COUNT_FIELD.integerRange(
+          ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT);
+
   /** Total number of published inline comments of the change, including robot comments. */
-  public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
-      intRange("total_comments")
+  public static final IndexedField<ChangeData, Integer> TOTAL_COMMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("TotalCommentCount")
+          .stored()
           .build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec TOTAL_COMMENT_COUNT_SPEC =
+      TOTAL_COMMENT_COUNT_FIELD.integerRange("total_comments");
+
   /** Whether the change is mergeable. */
-  public static final FieldDef<ChangeData, String> MERGEABLE =
-      exact(ChangeQueryBuilder.FIELD_MERGEABLE)
+  public static final IndexedField<ChangeData, String> MERGEABLE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Mergeable")
           .stored()
+          .size(1)
           .build(
               cd -> {
                 Boolean m = cd.isMergeable();
@@ -874,10 +1151,14 @@
               },
               (cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec MERGEABLE_SPEC =
+      MERGEABLE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGEABLE);
+
   /** Whether the change is a merge commit. */
-  public static final FieldDef<ChangeData, String> MERGE =
-      exact(ChangeQueryBuilder.FIELD_MERGE)
+  public static final IndexedField<ChangeData, String> MERGE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Merge")
           .stored()
+          .size(1)
           .build(
               cd -> {
                 Boolean m = cd.isMerge();
@@ -887,48 +1168,89 @@
                 return m ? "1" : "0";
               });
 
+  public static final IndexedField<ChangeData, String>.SearchSpec MERGE_SPEC =
+      MERGE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGE);
+
   /** Whether the change is a cherry pick of another change. */
-  public static final FieldDef<ChangeData, String> CHERRY_PICK =
-      exact(ChangeQueryBuilder.FIELD_CHERRYPICK)
+  public static final IndexedField<ChangeData, String> CHERRY_PICK_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CherryPick")
           .stored()
+          .size(1)
           .build(cd -> cd.change().getCherryPickOf() != null ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec CHERRY_PICK_SPEC =
+      CHERRY_PICK_FIELD.exact(ChangeQueryBuilder.FIELD_CHERRYPICK);
+
   /** The number of inserted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> ADDED =
-      intRange(ChangeQueryBuilder.FIELD_ADDED)
+  public static final IndexedField<ChangeData, Integer> ADDED_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("AddedLines")
+          .stored()
           .build(
               cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
-              (cd, field) -> cd.setLinesInserted(field));
+              (cd, field) -> {
+                if (field != null) {
+                  cd.setLinesInserted(field);
+                }
+              });
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ADDED_LINES_SPEC =
+      ADDED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_ADDED);
 
   /** The number of deleted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELETED =
-      intRange(ChangeQueryBuilder.FIELD_DELETED)
+  public static final IndexedField<ChangeData, Integer> DELETED_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("DeletedLines")
+          .stored()
           .build(
               cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
-              (cd, field) -> cd.setLinesDeleted(field));
+              (cd, field) -> {
+                if (field != null) {
+                  cd.setLinesDeleted(field);
+                }
+              });
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec DELETED_LINES_SPEC =
+      DELETED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELETED);
 
   /** The total number of modified lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELTA =
-      intRange(ChangeQueryBuilder.FIELD_DELTA)
+  public static final IndexedField<ChangeData, Integer> DELTA_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("DeltaLines")
+          .stored()
           .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec DELTA_LINES_SPEC =
+      DELTA_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELTA);
+
   /** Determines if this change is private. */
-  public static final FieldDef<ChangeData, String> PRIVATE =
-      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+  public static final IndexedField<ChangeData, String> PRIVATE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Private")
+          .size(1)
+          .build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec PRIVATE_SPEC =
+      PRIVATE_FIELD.exact(ChangeQueryBuilder.FIELD_PRIVATE);
 
   /** Determines if this change is work in progress. */
-  public static final FieldDef<ChangeData, String> WIP =
-      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+  public static final IndexedField<ChangeData, String> WIP_FIELD =
+      IndexedField.<ChangeData>stringBuilder("WIP")
+          .size(1)
+          .build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec WIP_SPEC =
+      WIP_FIELD.exact(ChangeQueryBuilder.FIELD_WIP);
 
   /** Determines if this change has started review. */
-  public static final FieldDef<ChangeData, String> STARTED =
-      exact(ChangeQueryBuilder.FIELD_STARTED)
+  public static final IndexedField<ChangeData, String> STARTED_FIELD =
+      IndexedField.<ChangeData>stringBuilder("ReviewStarted")
+          .size(1)
           .build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec STARTED_SPEC =
+      STARTED_FIELD.exact(ChangeQueryBuilder.FIELD_STARTED);
+
   /** Users who have commented on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
-      integer(ChangeQueryBuilder.FIELD_COMMENTBY)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<Integer>> COMMENTBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("CommentBy")
+          .build(
               cd ->
                   Stream.concat(
                           cd.messages().stream().map(ChangeMessage::getAuthor),
@@ -937,11 +1259,14 @@
                       .map(Account.Id::get)
                       .collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec COMMENTBY_SPEC =
+      COMMENTBY_FIELD.integer(ChangeQueryBuilder.FIELD_COMMENTBY);
+
   /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
-  public static final FieldDef<ChangeData, Iterable<String>> STAR =
-      exact(ChangeQueryBuilder.FIELD_STAR)
+  public static final IndexedField<ChangeData, Iterable<String>> STAR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Star")
           .stored()
-          .buildRepeatable(
+          .build(
               cd ->
                   Iterables.transform(
                       cd.stars().entries(),
@@ -953,33 +1278,61 @@
                           .map(f -> StarredChangesUtil.StarField.parse(f))
                           .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec STAR_SPEC =
+      STAR_FIELD.exact(ChangeQueryBuilder.FIELD_STAR);
+
   /** Users that have starred the change with any label. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
-      integer(ChangeQueryBuilder.FIELD_STARBY)
-          .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+  public static final IndexedField<ChangeData, Iterable<Integer>> STARBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("StarBy")
+          .build(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec STARBY_SPEC =
+      STARBY_FIELD.integer(ChangeQueryBuilder.FIELD_STARBY);
 
   /** Opaque group identifiers for this change's patch sets. */
-  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
-      exact(ChangeQueryBuilder.FIELD_GROUP)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> GROUP_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Group")
+          .build(
               cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec GROUP_SPEC =
+      GROUP_FIELD.exact(ChangeQueryBuilder.FIELD_GROUP);
+
   /** Serialized patch set object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
-      storedOnly("_patch_set")
-          .buildRepeatable(
-              cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
-              (cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
+  private static final TypeToken<Iterable<Entities.PatchSet>> PATCH_SET_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>> PATCH_SET_FIELD =
+      IndexedField.<ChangeData, Iterable<Entities.PatchSet>>builder(
+              "PatchSet", PATCH_SET_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(PatchSetProtoConverter.INSTANCE))
+          .build(
+              cd -> entitiesToProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
+              (cd, value) ->
+                  cd.setPatchSets(decodeProtosToEntities(value, PatchSetProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>>.SearchSpec
+      PATCH_SET_SPEC = PATCH_SET_FIELD.storedOnly("_patch_set");
 
   /** Users who have edits on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
-      integer(ChangeQueryBuilder.FIELD_EDITBY)
-          .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<Integer>> EDITBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("EditBy")
+          .build(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec EDITBY_SPEC =
+      EDITBY_FIELD.integer(ChangeQueryBuilder.FIELD_EDITBY);
 
   /** Users who have draft comments on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
-      integer(ChangeQueryBuilder.FIELD_DRAFTBY)
-          .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<Integer>> DRAFTBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("DraftBy")
+          .build(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec DRAFTBY_SPEC =
+      DRAFTBY_FIELD.integer(ChangeQueryBuilder.FIELD_DRAFTBY);
 
   public static final Integer NOT_REVIEWED = -1;
 
@@ -993,10 +1346,10 @@
    * <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
    * emitted.
    */
-  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
-      integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
+  public static final IndexedField<ChangeData, Iterable<Integer>> REVIEWEDBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("ReviewedBy")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> {
                 Set<Account.Id> reviewedBy = cd.reviewedBy();
                 if (reviewedBy.isEmpty()) {
@@ -1010,6 +1363,9 @@
                           .map(Account::id)
                           .collect(toImmutableSet())));
 
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec REVIEWEDBY_SPEC =
+      REVIEWEDBY_FIELD.integer(ChangeQueryBuilder.FIELD_REVIEWEDBY);
+
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
       SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
 
@@ -1017,9 +1373,9 @@
       SubmitRuleOptions.builder().build();
 
   /** All submit rules results in the form of "$ruleName,$status". */
-  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT =
-      exact("submit_rule_result")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("SubmitRuleResult")
+          .build(
               cd -> {
                 List<String> result = new ArrayList<>();
                 List<SubmitRecord> submitRecords = cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT);
@@ -1029,6 +1385,9 @@
                 return result;
               });
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+      SUBMIT_RULE_RESULT_SPEC = SUBMIT_RULE_RESULT_FIELD.exact("submit_rule_result");
+
   /**
    * JSON type for storing SubmitRecords.
    *
@@ -1115,12 +1474,17 @@
     }
   }
 
-  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
-      exact("submit_record").buildRepeatable(ChangeField::formatSubmitRecordValues);
+  public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RECORD_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("SubmitRecord")
+          .build(ChangeField::formatSubmitRecordValues);
 
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
-      storedOnly("full_submit_record_strict")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec SUBMIT_RECORD_SPEC =
+      SUBMIT_RECORD_FIELD.exact("submit_record");
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordStrict")
+          .stored()
+          .build(
               cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
               (cd, field) ->
                   parseSubmitRecords(
@@ -1130,17 +1494,27 @@
                       SUBMIT_RULE_OPTIONS_STRICT,
                       cd));
 
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
-      storedOnly("full_submit_record_lenient")
-          .buildRepeatable(
-              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
-              (cd, field) ->
-                  parseSubmitRecords(
-                      StreamSupport.stream(field.spliterator(), false)
-                          .map(f -> new String(f, UTF_8))
-                          .collect(toSet()),
-                      SUBMIT_RULE_OPTIONS_LENIENT,
-                      cd));
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      STORED_SUBMIT_RECORD_STRICT_SPEC =
+          STORED_SUBMIT_RECORD_STRICT_FIELD.storedOnly("full_submit_record_strict");
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>>
+      STORED_SUBMIT_RECORD_LENIENT_FIELD =
+          IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordLenient")
+              .stored()
+              .build(
+                  cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
+                  (cd, field) ->
+                      parseSubmitRecords(
+                          StreamSupport.stream(field.spliterator(), false)
+                              .map(f -> new String(f, UTF_8))
+                              .collect(toSet()),
+                          SUBMIT_RULE_OPTIONS_LENIENT,
+                          cd));
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      STORED_SUBMIT_RECORD_LENIENT_SPEC =
+          STORED_SUBMIT_RECORD_LENIENT_FIELD.storedOnly("full_submit_record_lenient");
 
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
@@ -1165,8 +1539,18 @@
   }
 
   public static List<String> formatSubmitRecordValues(ChangeData cd) {
-    return formatSubmitRecordValues(
-        cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner());
+    Set<String> submitRecordValues = new HashSet<>();
+    submitRecordValues.addAll(
+        formatSubmitRecordValues(
+            cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner()));
+    // Also backfill results of submit requirements such that users can query submit requirement
+    // results using the label operator, for example a query with "label:CR=NEED" will match with
+    // changes that have a submit-requirement with name="CR" and status=UNSATISFIED.
+    // Reason: We are preserving backward compatibility of the operators `label:$name=$status`
+    // which were previously working with submit records. Now admins can configure submit
+    // requirements and continue querying them with the label operator.
+    submitRecordValues.addAll(formatSubmitRequirementValues(cd.submitRequirements().values()));
+    return submitRecordValues.stream().collect(Collectors.toList());
   }
 
   @VisibleForTesting
@@ -1192,23 +1576,79 @@
     return result;
   }
 
-  /** Serialized submit requirements, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
-      storedOnly("full_submit_requirements")
-          .buildRepeatable(
-              cd ->
-                  toProtos(
-                      SubmitRequirementProtoConverter.INSTANCE, cd.submitRequirements().values()),
-              (cd, field) -> parseSubmitRequirements(field, cd));
+  /**
+   * Generate submit requirement result formats that are compatible with the legacy submit record
+   * statuses.
+   */
+  @VisibleForTesting
+  static List<String> formatSubmitRequirementValues(Collection<SubmitRequirementResult> srResults) {
+    List<String> result = new ArrayList<>();
+    for (SubmitRequirementResult srResult : srResults) {
+      switch (srResult.status()) {
+        case SATISFIED:
+        case OVERRIDDEN:
+        case FORCED:
+          result.add(
+              SubmitRecord.Label.Status.OK.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          result.add(
+              SubmitRecord.Label.Status.MAY.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          break;
+        case UNSATISFIED:
+          result.add(
+              SubmitRecord.Label.Status.NEED.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          result.add(
+              SubmitRecord.Label.Status.REJECT.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          break;
+        case NOT_APPLICABLE:
+        case ERROR:
+          result.add(
+              SubmitRecord.Label.Status.IMPOSSIBLE.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+      }
+    }
+    return result;
+  }
 
-  private static void parseSubmitRequirements(Iterable<byte[]> values, ChangeData out) {
+  /** Serialized submit requirements, used for pre-populating results. */
+  private static final TypeToken<Iterable<Cache.SubmitRequirementResultProto>>
+      STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN =
+          new TypeToken<>() {
+            private static final long serialVersionUID = 1L;
+          };
+
+  public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+      STORED_SUBMIT_REQUIREMENTS_FIELD =
+          IndexedField.<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>builder(
+                  "StoredSubmitRequirements", STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN)
+              .stored()
+              .required()
+              .protoConverter(Optional.of(SubmitRequirementProtoConverter.INSTANCE))
+              .build(
+                  cd ->
+                      entitiesToProtos(
+                          SubmitRequirementProtoConverter.INSTANCE,
+                          cd.submitRequirements().values()),
+                  (cd, value) -> parseSubmitRequirements(value, cd));
+
+  public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+          .SearchSpec
+      STORED_SUBMIT_REQUIREMENTS_SPEC =
+          STORED_SUBMIT_REQUIREMENTS_FIELD.storedOnly("full_submit_requirements");
+
+  private static void parseSubmitRequirements(
+      Iterable<Cache.SubmitRequirementResultProto> values, ChangeData out) {
     out.setSubmitRequirements(
-        StreamSupport.stream(values.spliterator(), false)
-            .map(
-                f ->
-                    SubmitRequirementProtoConverter.INSTANCE.fromProto(
-                        Protos.parseUnchecked(
-                            SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+        decodeProtosToEntities(values, SubmitRequirementProtoConverter.INSTANCE).stream()
+            .filter(sr -> !sr.isLegacy())
             .collect(
                 ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
   }
@@ -1218,9 +1658,10 @@
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("RefState")
+          .stored()
+          .build(
               cd -> {
                 List<byte[]> result = new ArrayList<>();
                 cd.getRefStates()
@@ -1230,15 +1671,19 @@
               },
               (cd, field) -> cd.setRefStates(RefState.parseStates(field)));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
+
   /**
    * All ref wildcard patterns that were used in the course of indexing this document.
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
    * RefStatePattern} for the pattern format.
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
-      storedOnly("ref_state_pattern")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("RefStatePattern")
+          .stored()
+          .build(
               cd -> {
                 Change.Id id = cd.getId();
                 Project.NameKey project = cd.change().getProject();
@@ -1257,6 +1702,10 @@
               },
               (cd, field) -> cd.setRefStatePatterns(field));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_PATTERN_SPEC =
+      REF_STATE_PATTERN_FIELD.storedOnly("ref_state_pattern");
+
+  @Nullable
   private static String getTopic(ChangeData cd) {
     Change c = cd.change();
     if (c == null) {
@@ -1265,31 +1714,77 @@
     return firstNonNull(c.getTopic(), "");
   }
 
-  private static <T> List<byte[]> toProtos(ProtoConverter<?, T> converter, Collection<T> objects) {
-    return objects.stream().map(object -> toProto(converter, object)).collect(toImmutableList());
+  private static <V extends MessageLite, T> V entityToProto(
+      ProtoConverter<V, T> converter, T object) {
+    return converter.toProto(object);
   }
 
-  private static <T> byte[] toProto(ProtoConverter<?, T> converter, T object) {
-    return Protos.toByteArray(converter.toProto(object));
-  }
-
-  private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
-    return StreamSupport.stream(raw.spliterator(), false)
-        .map(bytes -> parseProtoFrom(bytes, converter))
+  private static <V extends MessageLite, T> List<V> entitiesToProtos(
+      ProtoConverter<V, T> converter, Collection<T> objects) {
+    return objects.stream()
+        .map(object -> entityToProto(converter, object))
         .collect(toImmutableList());
   }
 
-  private static <P extends MessageLite, T> T parseProtoFrom(
-      byte[] bytes, ProtoConverter<P, T> converter) {
-    P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
-    return converter.fromProto(message);
+  private static <V extends MessageLite, T> List<T> decodeProtosToEntities(
+      Iterable<V> raw, ProtoConverter<V, T> converter) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(proto -> decodeProtoToEntity(proto, converter))
+        .collect(toImmutableList());
   }
 
-  private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
+  private static <V extends MessageLite, T> T decodeProtoToEntity(
+      V proto, ProtoConverter<V, T> converter) {
+    return converter.fromProto(proto);
+  }
+
+  private static <T> SchemaFieldDefs.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
 
   private static AllUsersName allUsers(ChangeData cd) {
     return cd.getAllUsersNameForIndexing();
   }
+
+  private static String truncateStringValueToMaxTermLength(String str) {
+    return truncateStringValue(str, MAX_TERM_LENGTH);
+  }
+
+  @VisibleForTesting
+  static String truncateStringValue(String str, int maxBytes) {
+    if (maxBytes < 0) {
+      throw new IllegalArgumentException("maxBytes < 0 not allowed");
+    }
+
+    if (maxBytes == 0) {
+      return "";
+    }
+
+    if (str.length() > maxBytes) {
+      if (Character.isHighSurrogate(str.charAt(maxBytes - 1))) {
+        str = str.substring(0, maxBytes - 1);
+      } else {
+        str = str.substring(0, maxBytes);
+      }
+    }
+    byte[] strBytes = str.getBytes(UTF_8);
+    if (strBytes.length > maxBytes) {
+      while (maxBytes > 0 && (strBytes[maxBytes] & 0xC0) == 0x80) {
+        maxBytes -= 1;
+      }
+      if (maxBytes > 0) {
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xE0) == 0xC0) {
+          maxBytes -= 1;
+        }
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF0) == 0xE0) {
+          maxBytes -= 1;
+        }
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF8) == 0xF0) {
+          maxBytes -= 1;
+        }
+      }
+      return new String(Arrays.copyOfRange(strBytes, 0, maxBytes), UTF_8);
+    }
+    return str;
+  }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 05c5c77..74e9af1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -30,8 +31,8 @@
 
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
-    return getSchema().useLegacyNumericFields()
-        ? ChangePredicates.id(id)
-        : ChangePredicates.idStr(id);
+    return ChangePredicates.idStr(id);
   }
+
+  Function<ChangeData, Change.Id> ENTITY_TO_KEY = ChangeData::getId;
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 63c5297..bb4b24c 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -19,17 +19,21 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.query.AndCardinalPredicate;
 import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.OrCardinalPredicate;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -39,12 +43,14 @@
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.IsSubmittablePredicate;
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.BitSet;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.util.MutableInteger;
 
@@ -83,7 +89,7 @@
     return s != null ? s : EnumSet.allOf(Change.Status.class);
   }
 
-  private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
+  private static @Nullable EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
     if (in instanceof ChangeStatusPredicate) {
       Status status = ((ChangeStatusPredicate) in).getStatus();
       return status != null ? EnumSet.of(status) : null;
@@ -156,6 +162,9 @@
 
     MutableInteger leafTerms = new MutableInteger();
     Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
     if (isSameInstance(in, out) || out instanceof IndexPredicate) {
       return new IndexedChangeQuery(index, out, opts);
     } else if (out == null /* cannot rewrite */) {
@@ -179,13 +188,13 @@
    * @throws QueryParseException if the underlying index implementation does not support this
    *     predicate.
    */
+  @Nullable
   private Predicate<ChangeData> rewriteImpl(
       Predicate<ChangeData> in, ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
       throws QueryParseException {
+    in = IsSubmittablePredicate.rewrite(in);
     if (isIndexPredicate(in, index)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+      ++leafTerms.value;
       return in;
     } else if (in instanceof LimitPredicate) {
       // Replace any limits with the limit provided by the caller. The caller
@@ -243,9 +252,9 @@
     }
     IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
 
-    FieldDef<ChangeData, ?> def = p.getField();
+    SchemaField<ChangeData, ?> field = p.getField();
     Schema<ChangeData> schema = index.getSchema();
-    return schema.hasField(def);
+    return schema.hasField(field);
   }
 
   private Predicate<ChangeData> partitionChildren(
@@ -257,7 +266,8 @@
       throws QueryParseException {
     if (isIndexed.cardinality() == 1) {
       int i = isIndexed.nextSetBit(0);
-      newChildren.add(0, new IndexedChangeQuery(index, newChildren.remove(i), opts));
+      Predicate<ChangeData> indexed = newChildren.remove(i);
+      newChildren.add(0, new IndexedChangeQuery(index, copy(indexed, indexed.getChildren()), opts));
       return copy(in, newChildren);
     }
 
@@ -275,15 +285,33 @@
         all.add(c);
       }
     }
-    all.add(0, new IndexedChangeQuery(index, in.copy(indexed), opts));
+    all.add(0, new IndexedChangeQuery(index, copy(in, indexed), opts));
     return copy(in, all);
   }
 
   private Predicate<ChangeData> copy(Predicate<ChangeData> in, List<Predicate<ChangeData>> all) {
     if (in instanceof AndPredicate) {
-      return new AndChangeSource(all);
+      Optional<Predicate<ChangeData>> atLeastOneChangeDataSource =
+          all.stream().filter(p -> (p instanceof ChangeDataSource)).findAny();
+      if (atLeastOneChangeDataSource.isPresent()) {
+        return new AndChangeSource(all, config);
+      }
+      Optional<Predicate<ChangeData>> atLeastOneCardinalPredicate =
+          all.stream().filter(p -> (p instanceof HasCardinality)).findAny();
+      if (atLeastOneCardinalPredicate.isPresent()) {
+        return new AndCardinalPredicate<>(all);
+      }
     } else if (in instanceof OrPredicate) {
-      return new OrSource(all);
+      Optional<Predicate<ChangeData>> nonChangeDataSource =
+          all.stream().filter(p -> !(p instanceof ChangeDataSource)).findAny();
+      if (!nonChangeDataSource.isPresent()) {
+        return new OrSource(all);
+      }
+      Optional<Predicate<ChangeData>> nonHasCardinality =
+          all.stream().filter(p -> !(p instanceof HasCardinality)).findAny();
+      if (!nonHasCardinality.isPresent()) {
+        return new OrCardinalPredicate<>(all);
+      }
     }
     return in.copy(all);
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index f7f0f33..dc3907d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.StalenessCheckResult;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -42,11 +43,10 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -79,6 +79,7 @@
   private final PluginSetContext<ChangeIndexedListener> indexedListeners;
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   private final Map<Change.Id, IndexTask> queuedIndexTasks = new ConcurrentHashMap<>();
   private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
@@ -94,7 +95,8 @@
       StalenessChecker stalenessChecker,
       @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
-      @Assisted ChangeIndex index) {
+      @Assisted ChangeIndex index,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -105,6 +107,7 @@
     this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = index;
     this.indexes = null;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @AssistedInject
@@ -117,7 +120,8 @@
       StalenessChecker stalenessChecker,
       @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
-      @Assisted ChangeIndexCollection indexes) {
+      @Assisted ChangeIndexCollection indexes,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -128,6 +132,7 @@
     this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = null;
     this.indexes = indexes;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   private static boolean autoReindexIfStale(Config cfg) {
@@ -178,21 +183,6 @@
   }
 
   /**
-   * Start indexing multiple changes in parallel.
-   *
-   * @param ids changes to index.
-   * @return future for completing indexing of all changes.
-   */
-  public ListenableFuture<List<ChangeData>> indexAsync(
-      Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      futures.add(indexAsync(project, id));
-    }
-    return Futures.allAsList(futures);
-  }
-
-  /**
    * Synchronously index a change, then check if the index is stale due to a race condition.
    *
    * @param cd change to index.
@@ -227,21 +217,25 @@
   }
 
   private void indexImpl(ChangeData cd) {
-    logger.atFine().log("Replace change %d in index.", cd.getId().get());
+    logger.atFine().log("Reindex change %d in index.", cd.getId().get());
     for (Index<?, ChangeData> i : getWriteIndexes()) {
       try (TraceTimer traceTimer =
           TraceContext.newTimer(
-              "Replacing change in index",
+              "Reindexing change in index",
               Metadata.builder()
                   .changeId(cd.getId().get())
                   .patchSetId(cd.currentPatchSet().number())
                   .indexVersion(i.getSchema().getVersion())
                   .build())) {
-        i.replace(cd);
+        if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+          i.insert(cd);
+        } else {
+          i.replace(cd);
+        }
       } catch (RuntimeException e) {
         throw new StorageException(
             String.format(
-                "Failed to replace change %d in index version %d (current patch set = %d)",
+                "Failed to reindex change %d in index version %d (current patch set = %d)",
                 cd.getId().get(), i.getSchema().getVersion(), cd.currentPatchSet().number()),
             e);
       }
@@ -290,9 +284,9 @@
    * @param id change to delete.
    * @return future for the deleting task, the result of the future is always {@code null}
    */
-  public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
+  public ListenableFuture<ChangeData> deleteAsync(Project.NameKey project, Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
-    return submit(new DeleteTask(id));
+    return submit(new DeleteTask(id, Optional.of(project)));
   }
 
   /**
@@ -305,8 +299,12 @@
     doDelete(id);
   }
 
+  private void doDelete(Project.NameKey project, Change.Id id) {
+    new DeleteTask(id, Optional.of(project)).call();
+  }
+
   private void doDelete(Change.Id id) {
-    new DeleteTask(id).call();
+    new DeleteTask(id, Optional.empty()).call();
   }
 
   /**
@@ -414,6 +412,7 @@
       return future;
     }
 
+    @Nullable
     @Override
     public ChangeData callImpl() throws Exception {
       // Remove this task from queuedIndexTasks. This is done right at the beginning of this task so
@@ -429,7 +428,7 @@
         doIndex(changeData);
         return changeData;
       } catch (NoSuchChangeException e) {
-        doDelete(id);
+        doDelete(project, id);
       }
       return null;
     }
@@ -462,11 +461,14 @@
   // Not AbstractIndexTask as it doesn't need a request context.
   private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
+    private final Optional<Project.NameKey> project;
 
-    private DeleteTask(Change.Id id) {
+    private DeleteTask(Change.Id id, Optional<Project.NameKey> project) {
       this.id = id;
+      this.project = project;
     }
 
+    @Nullable
     @Override
     public ChangeData call() {
       logger.atFine().log("Delete change %d from index.", id.get());
@@ -481,7 +483,12 @@
                     .changeId(id.get())
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
-          i.delete(id);
+          // Some index implementation require ProjectKey to build a database key
+          // If delete(K) method is used, this will require changeId -> projectKey lookup (index
+          // query), which is expensive.
+          // Use changeData with ProjectKey and deleteByValue(V) method, if possible
+          project.ifPresentOrElse(
+              p -> i.deleteByValue(changeDataFactory.create(p, id)), () -> i.delete(id));
         } catch (RuntimeException e) {
           throw new StorageException(
               String.format(
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index ee93065..895c4d8 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -16,184 +16,227 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.query.change.ChangeData;
 
-/** Definition of change index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of change index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE_SPEC} based on submit requirements. */
   @Deprecated
-  static final Schema<ChangeData> V55 =
+  static final Schema<ChangeData> V74 =
       schema(
-          ChangeField.ADDED,
-          ChangeField.APPROVAL,
-          ChangeField.ASSIGNEE,
-          ChangeField.AUTHOR,
-          ChangeField.CHANGE,
-          ChangeField.COMMENT,
-          ChangeField.COMMENTBY,
-          ChangeField.COMMIT,
-          ChangeField.COMMITTER,
-          ChangeField.COMMIT_MESSAGE,
-          ChangeField.DELETED,
-          ChangeField.DELTA,
-          ChangeField.DIRECTORY,
-          ChangeField.DRAFTBY,
-          ChangeField.EDITBY,
-          ChangeField.EXACT_AUTHOR,
-          ChangeField.EXACT_COMMIT,
-          ChangeField.EXACT_COMMITTER,
-          ChangeField.EXACT_TOPIC,
-          ChangeField.EXTENSION,
-          ChangeField.FILE_PART,
-          ChangeField.FOOTER,
-          ChangeField.FUZZY_TOPIC,
-          ChangeField.GROUP,
-          ChangeField.HASHTAG,
-          ChangeField.HASHTAG_CASE_AWARE,
-          ChangeField.ID,
-          ChangeField.LABEL,
-          ChangeField.LEGACY_ID,
-          ChangeField.MERGEABLE,
-          ChangeField.ONLY_EXTENSIONS,
-          ChangeField.OWNER,
-          ChangeField.PATCH_SET,
-          ChangeField.PATH,
-          ChangeField.PENDING_REVIEWER,
-          ChangeField.PENDING_REVIEWER_BY_EMAIL,
-          ChangeField.PRIVATE,
-          ChangeField.PROJECT,
-          ChangeField.PROJECTS,
-          ChangeField.REF,
-          ChangeField.REF_STATE,
-          ChangeField.REF_STATE_PATTERN,
-          ChangeField.REVERT_OF,
-          ChangeField.REVIEWEDBY,
-          ChangeField.REVIEWER,
-          ChangeField.REVIEWER_BY_EMAIL,
-          ChangeField.STAR,
-          ChangeField.STARBY,
-          ChangeField.STARTED,
-          ChangeField.STATUS,
-          ChangeField.STORED_SUBMIT_RECORD_LENIENT,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT,
-          ChangeField.SUBMISSIONID,
-          ChangeField.SUBMIT_RECORD,
-          ChangeField.TOTAL_COMMENT_COUNT,
-          ChangeField.TR,
-          ChangeField.UNRESOLVED_COMMENT_COUNT,
-          ChangeField.UPDATED,
-          ChangeField.WIP);
+          /* version= */ 74,
+          ImmutableList.of(),
+          ImmutableList.<IndexedField<ChangeData, ?>>of(
+              ChangeField.ADDED_LINES_FIELD,
+              ChangeField.APPROVAL_FIELD,
+              ChangeField.ASSIGNEE_FIELD,
+              ChangeField.ATTENTION_SET_FULL_FIELD,
+              ChangeField.ATTENTION_SET_USERS_COUNT_FIELD,
+              ChangeField.ATTENTION_SET_USERS_FIELD,
+              ChangeField.AUTHOR_PARTS_FIELD,
+              ChangeField.CHANGE_FIELD,
+              ChangeField.CHANGE_ID_FIELD,
+              ChangeField.CHERRY_PICK_FIELD,
+              ChangeField.CHERRY_PICK_OF_CHANGE_FIELD,
+              ChangeField.CHERRY_PICK_OF_PATCHSET_FIELD,
+              ChangeField.COMMENTBY_FIELD,
+              ChangeField.COMMENT_FIELD,
+              ChangeField.COMMITTER_PARTS_FIELD,
+              ChangeField.COMMIT_FIELD,
+              ChangeField.COMMIT_MESSAGE_FIELD,
+              ChangeField.DELETED_LINES_FIELD,
+              ChangeField.DELTA_LINES_FIELD,
+              ChangeField.DIRECTORY_FIELD,
+              ChangeField.DRAFTBY_FIELD,
+              ChangeField.EDITBY_FIELD,
+              ChangeField.EXACT_AUTHOR_FIELD,
+              ChangeField.EXACT_COMMITTER_FIELD,
+              ChangeField.EXTENSION_FIELD,
+              ChangeField.FILE_PART_FIELD,
+              ChangeField.FOOTER_FIELD,
+              ChangeField.GROUP_FIELD,
+              ChangeField.HASHTAG_CASE_AWARE_FIELD,
+              ChangeField.HASHTAG_FIELD,
+              ChangeField.IS_PURE_REVERT_FIELD,
+              ChangeField.IS_SUBMITTABLE_FIELD,
+              ChangeField.LABEL_FIELD,
+              ChangeField.MERGEABLE_FIELD,
+              ChangeField.MERGED_ON_FIELD,
+              ChangeField.MERGE_FIELD,
+              ChangeField.NUMERIC_ID_STR_FIELD,
+              ChangeField.ONLY_EXTENSIONS_FIELD,
+              ChangeField.OWNER_FIELD,
+              ChangeField.PATCH_SET_FIELD,
+              ChangeField.PATH_FIELD,
+              ChangeField.PENDING_REVIEWER_BY_EMAIL_FIELD,
+              ChangeField.PENDING_REVIEWER_FIELD,
+              ChangeField.PRIVATE_FIELD,
+              ChangeField.PROJECT_FIELD,
+              ChangeField.REF_FIELD,
+              ChangeField.REF_STATE_FIELD,
+              ChangeField.REF_STATE_PATTERN_FIELD,
+              ChangeField.REVERT_OF_FIELD,
+              ChangeField.REVIEWEDBY_FIELD,
+              ChangeField.REVIEWER_BY_EMAIL_FIELD,
+              ChangeField.REVIEWER_FIELD,
+              ChangeField.STARBY_FIELD,
+              ChangeField.STARTED_FIELD,
+              ChangeField.STAR_FIELD,
+              ChangeField.STATUS_FIELD,
+              ChangeField.STORED_SUBMIT_RECORD_LENIENT_FIELD,
+              ChangeField.STORED_SUBMIT_RECORD_STRICT_FIELD,
+              ChangeField.STORED_SUBMIT_REQUIREMENTS_FIELD,
+              ChangeField.SUBMISSIONID_FIELD,
+              ChangeField.SUBMIT_RECORD_FIELD,
+              ChangeField.SUBMIT_RULE_RESULT_FIELD,
+              ChangeField.TOPIC_FIELD,
+              ChangeField.TOTAL_COMMENT_COUNT_FIELD,
+              ChangeField.TR_FIELD,
+              ChangeField.UNRESOLVED_COMMENT_COUNT_FIELD,
+              ChangeField.UPDATED_FIELD,
+              ChangeField.UPLOADER_FIELD,
+              ChangeField.WIP_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+              ChangeField.ADDED_LINES_SPEC,
+              ChangeField.APPROVAL_SPEC,
+              ChangeField.ASSIGNEE_SPEC,
+              ChangeField.ATTENTION_SET_FULL_SPEC,
+              ChangeField.ATTENTION_SET_USERS,
+              ChangeField.ATTENTION_SET_USERS_COUNT,
+              ChangeField.AUTHOR_PARTS_SPEC,
+              ChangeField.CHANGE_ID_SPEC,
+              ChangeField.CHANGE_SPEC,
+              ChangeField.CHERRY_PICK_OF_CHANGE,
+              ChangeField.CHERRY_PICK_OF_PATCHSET,
+              ChangeField.CHERRY_PICK_SPEC,
+              ChangeField.COMMENTBY_SPEC,
+              ChangeField.COMMENT_SPEC,
+              ChangeField.COMMITTER_PARTS_SPEC,
+              ChangeField.COMMIT_MESSAGE,
+              ChangeField.COMMIT_SPEC,
+              ChangeField.DELETED_LINES_SPEC,
+              ChangeField.DELTA_LINES_SPEC,
+              ChangeField.DIRECTORY_SPEC,
+              ChangeField.DRAFTBY_SPEC,
+              ChangeField.EDITBY_SPEC,
+              ChangeField.EXACT_AUTHOR_SPEC,
+              ChangeField.EXACT_COMMITTER_SPEC,
+              ChangeField.EXACT_COMMIT_SPEC,
+              ChangeField.EXACT_TOPIC,
+              ChangeField.EXTENSION_SPEC,
+              ChangeField.FILE_PART_SPEC,
+              ChangeField.FOOTER_SPEC,
+              ChangeField.FUZZY_HASHTAG,
+              ChangeField.FUZZY_TOPIC,
+              ChangeField.GROUP_SPEC,
+              ChangeField.HASHTAG_CASE_AWARE_SPEC,
+              ChangeField.HASHTAG_SPEC,
+              ChangeField.IS_PURE_REVERT_SPEC,
+              ChangeField.IS_SUBMITTABLE_SPEC,
+              ChangeField.LABEL_SPEC,
+              ChangeField.MERGEABLE_SPEC,
+              ChangeField.MERGED_ON_SPEC,
+              ChangeField.MERGE_SPEC,
+              ChangeField.NUMERIC_ID_STR_SPEC,
+              ChangeField.ONLY_EXTENSIONS_SPEC,
+              ChangeField.OWNER_SPEC,
+              ChangeField.PATCH_SET_SPEC,
+              ChangeField.PATH_SPEC,
+              ChangeField.PENDING_REVIEWER_BY_EMAIL,
+              ChangeField.PENDING_REVIEWER_SPEC,
+              ChangeField.PRIVATE_SPEC,
+              ChangeField.PROJECTS_SPEC,
+              ChangeField.PROJECT_SPEC,
+              ChangeField.REF_SPEC,
+              ChangeField.REF_STATE_PATTERN_SPEC,
+              ChangeField.REF_STATE_SPEC,
+              ChangeField.REVERT_OF,
+              ChangeField.REVIEWEDBY_SPEC,
+              ChangeField.REVIEWER_BY_EMAIL,
+              ChangeField.REVIEWER_SPEC,
+              ChangeField.STARBY_SPEC,
+              ChangeField.STARTED_SPEC,
+              ChangeField.STAR_SPEC,
+              ChangeField.STATUS_SPEC,
+              ChangeField.STORED_SUBMIT_RECORD_LENIENT_SPEC,
+              ChangeField.STORED_SUBMIT_RECORD_STRICT_SPEC,
+              ChangeField.STORED_SUBMIT_REQUIREMENTS_SPEC,
+              ChangeField.SUBMISSIONID_SPEC,
+              ChangeField.SUBMIT_RECORD_SPEC,
+              ChangeField.SUBMIT_RULE_RESULT_SPEC,
+              ChangeField.TOTAL_COMMENT_COUNT_SPEC,
+              ChangeField.TR_SPEC,
+              ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC,
+              ChangeField.UPDATED_SPEC,
+              ChangeField.UPLOADER_SPEC,
+              ChangeField.WIP_SPEC));
 
   /**
-   * The computation of the {@link ChangeField#EXTENSION} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V56 = schema(V55);
-
-  /**
-   * New numeric types: use dimensional points using the k-d tree geo-spatial data structure to
-   * offer fast single- and multi-dimensional numeric range. As the consequense, {@link
-   * ChangeField#LEGACY_ID} is replaced with {@link ChangeField#LEGACY_ID_STR}.
+   * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
+   * allow easier search for topics.
    */
   @Deprecated
-  static final Schema<ChangeData> V57 =
+  static final Schema<ChangeData> V75 =
       new Schema.Builder<ChangeData>()
-          .add(V56)
-          .remove(ChangeField.LEGACY_ID)
-          .add(ChangeField.LEGACY_ID_STR)
-          .legacyNumericFields(false)
+          .add(V74)
+          .addSearchSpecs(ChangeField.PREFIX_HASHTAG)
+          .addSearchSpecs(ChangeField.PREFIX_TOPIC)
           .build();
 
-  /**
-   * Added new fields {@link ChangeField#CHERRY_PICK_OF_CHANGE} and {@link
-   * ChangeField#CHERRY_PICK_OF_PATCHSET}.
-   */
+  /** Added new field {@link ChangeField#FOOTER_NAME}. */
   @Deprecated
-  static final Schema<ChangeData> V58 =
+  static final Schema<ChangeData> V76 =
       new Schema.Builder<ChangeData>()
-          .add(V57)
-          .add(ChangeField.CHERRY_PICK_OF_CHANGE)
-          .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
+          .add(V75)
+          .addIndexedFields(ChangeField.FOOTER_NAME_FIELD)
+          .addSearchSpecs(ChangeField.FOOTER_NAME)
           .build();
 
-  /**
-   * Added new fields {@link ChangeField#ATTENTION_SET_USERS} and {@link
-   * ChangeField#ATTENTION_SET_FULL}.
-   */
+  /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
   @Deprecated
-  static final Schema<ChangeData> V59 =
+  static final Schema<ChangeData> V77 =
       new Schema.Builder<ChangeData>()
-          .add(V58)
-          .add(ChangeField.ATTENTION_SET_USERS)
-          .add(ChangeField.ATTENTION_SET_FULL)
+          .add(V76)
+          .addIndexedFields(ChangeField.COMMIT_MESSAGE_EXACT_FIELD)
+          .addSearchSpecs(ChangeField.COMMIT_MESSAGE_EXACT)
           .build();
 
-  /** Added new fields {@link ChangeField#MERGE} */
+  // Upgrade Lucene to 7.x requires reindexing.
+  @Deprecated static final Schema<ChangeData> V78 = schema(V77);
+
+  /** Remove draft and star fields. */
   @Deprecated
-  static final Schema<ChangeData> V60 =
-      new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
+  static final Schema<ChangeData> V79 =
+      new Schema.Builder<ChangeData>()
+          .add(V78)
+          .remove(ChangeField.STAR_SPEC, ChangeField.STARBY_SPEC, ChangeField.DRAFTBY_SPEC)
+          .remove(ChangeField.STAR_FIELD, ChangeField.STARBY_FIELD, ChangeField.DRAFTBY_FIELD)
+          .build();
 
-  /** Added new field {@link ChangeField#MERGED_ON} */
+  /** Add subject field. */
   @Deprecated
-  static final Schema<ChangeData> V61 =
-      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
+  static final Schema<ChangeData> V80 =
+      new Schema.Builder<ChangeData>()
+          .add(V79)
+          .addIndexedFields(ChangeField.SUBJECT_FIELD)
+          .addSearchSpecs(ChangeField.SUBJECT_SPEC)
+          .build();
 
-  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
-  @Deprecated
-  static final Schema<ChangeData> V62 =
-      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
-
-  /**
-   * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V63 = schema(V62, false);
-
-  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
-  @Deprecated static final Schema<ChangeData> V64 = schema(V63, false);
-
-  /** Added new field for submit requirements. */
-  @Deprecated
-  static final Schema<ChangeData> V65 =
-      new Schema.Builder<ChangeData>().add(V64).add(ChangeField.STORED_SUBMIT_REQUIREMENTS).build();
-
-  /**
-   * The computation of {@link ChangeField#LABEL} has changed: We added the non_uploader arg to the
-   * label field.
-   */
-  @Deprecated static final Schema<ChangeData> V66 = schema(V65, false);
-
-  /** Updated submit records: store the rule name that created the submit record. */
-  @Deprecated static final Schema<ChangeData> V67 = schema(V66, false);
-
-  /** Added new field {@link ChangeField#SUBMIT_RULE_RESULT}. */
-  @Deprecated
-  static final Schema<ChangeData> V68 =
-      new Schema.Builder<ChangeData>().add(V67).add(ChangeField.SUBMIT_RULE_RESULT).build();
-
-  /** Added new field {@link ChangeField#CHERRY_PICK}. */
-  @Deprecated
-  static final Schema<ChangeData> V69 =
-      new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
-
-  /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
-  @Deprecated
-  static final Schema<ChangeData> V70 =
-      new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
-
-  /** Added new field {@link ChangeField#UPLOADER}. */
-  @Deprecated
-  static final Schema<ChangeData> V71 =
-      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
-
-  /** Added new field {@link ChangeField#IS_PURE_REVERT}. */
-  @Deprecated
-  static final Schema<ChangeData> V72 =
-      new Schema.Builder<ChangeData>().add(V71).add(ChangeField.IS_PURE_REVERT).build();
-
-  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
-  static final Schema<ChangeData> V73 = schema(V72, false);
+  /** Add prefixsubject field. */
+  static final Schema<ChangeData> V81 =
+      new Schema.Builder<ChangeData>()
+          .add(V80)
+          .addSearchSpecs(ChangeField.PREFIX_SUBJECT_SPEC)
+          .build();
 
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 57a2091..8f5e36e 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
@@ -34,6 +35,7 @@
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gerrit.server.query.change.ChangeIndexPostFilterPredicate;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -51,24 +53,43 @@
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
     implements ChangeDataSource, Matchable<ChangeData> {
   public static QueryOptions oneResult() {
-    return createOptions(IndexConfig.createDefault(), 0, 1, ImmutableSet.of());
+    IndexConfig config = IndexConfig.createDefault();
+    return createOptions(config, 0, 1, config.pageSizeMultiplier(), 1, ImmutableSet.of());
   }
 
   public static QueryOptions createOptions(
       IndexConfig config, int start, int limit, Set<String> fields) {
-    // Always include project since it is needed to load the change from NoteDb.
-    if (!fields.contains(CHANGE.getName()) && !fields.contains(PROJECT.getName())) {
+    return createOptions(config, start, limit, config.pageSizeMultiplier(), limit, fields);
+  }
+
+  public static QueryOptions createOptions(
+      IndexConfig config,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> fields) {
+    // Always include project and change id since both are needed to load the change from NoteDb.
+    if (!fields.contains(CHANGE_SPEC.getName())
+        && !(fields.contains(PROJECT_SPEC.getName())
+            && fields.contains(NUMERIC_ID_STR_SPEC.getName()))) {
       fields = new HashSet<>(fields);
-      fields.add(PROJECT.getName());
+      fields.add(PROJECT_SPEC.getName());
+      fields.add(NUMERIC_ID_STR_SPEC.getName());
     }
-    return QueryOptions.create(config, start, limit, fields);
+    return QueryOptions.create(config, start, pageSize, pageSizeMultiplier, limit, fields);
   }
 
   @VisibleForTesting
   static QueryOptions convertOptions(QueryOptions opts) {
     opts = opts.convertForBackend();
     return IndexedChangeQuery.createOptions(
-        opts.config(), opts.start(), opts.limit(), opts.fields());
+        opts.config(),
+        opts.start(),
+        opts.pageSize(),
+        opts.pageSizeMultiplier(),
+        opts.limit(),
+        opts.fields());
   }
 
   private final Map<ChangeData, DataSource<ChangeData>> fromSource;
@@ -84,7 +105,7 @@
     final DataSource<ChangeData> currSource = source;
     final ResultSet<ChangeData> rs = currSource.read();
 
-    return new ResultSet<ChangeData>() {
+    return new ResultSet<>() {
       @Override
       public Iterator<ChangeData> iterator() {
         return Iterables.transform(
@@ -109,16 +130,38 @@
       public void close() {
         rs.close();
       }
+
+      @Override
+      public Object searchAfter() {
+        return rs.searchAfter();
+      }
     };
   }
 
+  public boolean postIndexMatch(Predicate<ChangeData> pred, ChangeData cd) {
+    if (pred instanceof ChangeIndexPostFilterPredicate) {
+      checkState(
+          pred.isMatchable(),
+          "match invoked, but child predicate %s doesn't implement %s",
+          pred,
+          Matchable.class.getName());
+      return pred.asMatchable().match(cd);
+    }
+    for (int i = 0; i < pred.getChildCount(); i++) {
+      if (!postIndexMatch(pred.getChild(i), cd)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
   @Override
   public boolean match(ChangeData cd) {
-    if (source != null && fromSource.get(cd) == source) {
+    Predicate<ChangeData> pred = getChild(0);
+    if (source != null && fromSource.get(cd) == source && postIndexMatch(pred, cd)) {
       return true;
     }
 
-    Predicate<ChangeData> pred = getChild(0);
     checkState(
         pred.isMatchable(),
         "match invoked, but child predicate %s doesn't implement %s",
@@ -137,6 +180,6 @@
 
   @Override
   public boolean hasChange() {
-    return index.getSchema().hasField(ChangeField.CHANGE);
+    return index.getSchema().hasField(ChangeField.CHANGE_SPEC);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 49f6ff9..861a5fa 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -53,7 +53,7 @@
  *
  * <p>Will reindex accounts when the account's NoteDb ref changes.
  */
-public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
+public class ReindexAfterRefUpdate implements GitBatchRefUpdateListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final OneOffRequestContext requestContext;
@@ -86,48 +86,56 @@
   }
 
   @Override
-  public void onGitReferenceUpdated(Event event) {
-    if (allUsersName.get().equals(event.getProjectName())
-        && !RefNames.REFS_CONFIG.equals(event.getRefName())) {
-      Account.Id accountId = Account.Id.fromRef(event.getRefName());
-      if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
-        indexer.get().index(accountId);
+  public void onGitBatchRefUpdate(GitBatchRefUpdateListener.Event event) {
+    if (allUsersName.get().equals(event.getProjectName())) {
+      for (UpdatedRef ref : event.getUpdatedRefs()) {
+        if (!RefNames.REFS_CONFIG.equals(ref.getRefName())) {
+          if (ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
+            break;
+          }
+          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+          if (accountId != null) {
+            indexer.get().index(accountId);
+          }
+        }
       }
       // The update is in All-Users and not on refs/meta/config. So it's not a change. Return early.
       return;
     }
 
-    if (!enabled
-        || event.getRefName().startsWith(RefNames.REFS_CHANGES)
-        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
-        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
-      return;
-    }
-    Futures.addCallback(
-        executor.submit(new GetChanges(event)),
-        new FutureCallback<List<Change>>() {
-          @Override
-          public void onSuccess(List<Change> changes) {
-            for (Change c : changes) {
-              @SuppressWarnings("unused")
-              Future<?> possiblyIgnoredError =
-                  indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+    for (UpdatedRef ref : event.getUpdatedRefs()) {
+      if (!enabled
+          || ref.getRefName().startsWith(RefNames.REFS_CHANGES)
+          || ref.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
+          || ref.getRefName().startsWith(RefNames.REFS_USERS)) {
+        continue;
+      }
+      Futures.addCallback(
+          executor.submit(new GetChanges(event.getProjectName(), ref)),
+          new FutureCallback<List<Change>>() {
+            @Override
+            public void onSuccess(List<Change> changes) {
+              for (Change c : changes) {
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError =
+                    indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+              }
             }
-          }
 
-          @Override
-          public void onFailure(Throwable ignored) {
-            // Logged by {@link GetChanges#call()}.
-          }
-        },
-        directExecutor());
+            @Override
+            public void onFailure(Throwable ignored) {
+              // Logged by {@link GetChanges#call()}.
+            }
+          },
+          directExecutor());
+    }
   }
 
   private abstract class Task<V> implements Callable<V> {
-    protected Event event;
+    protected UpdatedRef updatedRef;
 
-    protected Task(Event event) {
-      this.event = event;
+    protected Task(UpdatedRef updatedRef) {
+      this.updatedRef = updatedRef;
     }
 
     @Override
@@ -135,7 +143,7 @@
       try (ManualRequestContext ctx = requestContext.open()) {
         return impl(ctx);
       } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", event);
+        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", updatedRef);
         throw e;
       }
     }
@@ -146,14 +154,17 @@
   }
 
   private class GetChanges extends Task<List<Change>> {
-    private GetChanges(Event event) {
-      super(event);
+    protected String projectName;
+
+    private GetChanges(String projectName, UpdatedRef updatedRef) {
+      super(updatedRef);
+      this.projectName = projectName;
     }
 
     @Override
     protected List<Change> impl(RequestContext ctx) {
-      String ref = event.getRefName();
-      Project.NameKey project = Project.nameKey(event.getProjectName());
+      String ref = updatedRef.getRefName();
+      Project.NameKey project = Project.nameKey(projectName);
       if (ref.equals(RefNames.REFS_CONFIG)) {
         return asChanges(queryProvider.get().byProjectOpen(project));
       }
@@ -163,9 +174,9 @@
     @Override
     public String toString() {
       return "Get changes to reindex caused by "
-          + event.getRefName()
+          + updatedRef.getRefName()
           + " update of project "
-          + event.getProjectName();
+          + projectName;
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index ad5cc2b..eb4af01 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -56,9 +56,9 @@
 
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
-          ChangeField.CHANGE.getName(),
-          ChangeField.REF_STATE.getName(),
-          ChangeField.REF_STATE_PATTERN.getName());
+          ChangeField.CHANGE_SPEC.getName(),
+          ChangeField.REF_STATE_SPEC.getName(),
+          ChangeField.REF_STATE_PATTERN_SPEC.getName());
 
   private final ChangeIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -82,8 +82,8 @@
       return StalenessCheckResult
           .notStale(); // No index; caller couldn't do anything if it is stale.
     }
-    if (!i.getSchema().hasField(ChangeField.REF_STATE)
-        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
+    if (!i.getSchema().hasField(ChangeField.REF_STATE_SPEC)
+        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN_SPEC)) {
       return StalenessCheckResult.notStale(); // Index version not new enough for this check.
     }
 
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index b3ef679..3773d435 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -54,15 +55,18 @@
   private final ListeningExecutorService executor;
   private final GroupCache groupCache;
   private final Groups groups;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   @Inject
   AllGroupsIndexer(
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       GroupCache groupCache,
-      Groups groups) {
+      Groups groups,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.groupCache = groupCache;
     this.groups = groups;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @Override
@@ -96,9 +100,14 @@
           executor.submit(
               () -> {
                 try {
+                  groupCache.evict(uuid);
                   InternalGroup internalGroup = reindexedGroups.get(uuid);
                   if (internalGroup != null) {
-                    index.replace(internalGroup);
+                    if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+                      index.insert(internalGroup);
+                    } else {
+                      index.replace(internalGroup);
+                    }
                   } else {
                     index.delete(uuid);
 
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index df90c0d..7a26f31 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -15,75 +15,125 @@
 package com.google.gerrit.server.index.group;
 
 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.integer;
-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 com.google.common.base.MoreObjects;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.SchemaUtil;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for groups. */
+/**
+ * Secondary index schemas for groups.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class GroupField {
   /** Legacy group ID. */
-  public static final FieldDef<InternalGroup, Integer> ID =
-      integer("id").build(g -> g.getId().get());
+  public static final IndexedField<InternalGroup, Integer> ID_FIELD =
+      IndexedField.<InternalGroup>integerBuilder("Id").required().build(g -> g.getId().get());
+
+  public static final IndexedField<InternalGroup, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
 
   /** Group UUID. */
-  public static final FieldDef<InternalGroup, String> UUID =
-      exact("uuid").stored().build(g -> g.getGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("UUID")
+          .required()
+          .stored()
+          .build(g -> g.getGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec UUID_FIELD_SPEC =
+      UUID_FIELD.exact("uuid");
 
   /** Group owner UUID. */
-  public static final FieldDef<InternalGroup, String> OWNER_UUID =
-      exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> OWNER_UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("OwnerUUID")
+          .required()
+          .build(g -> g.getOwnerGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec OWNER_UUID_SPEC =
+      OWNER_UUID_FIELD.exact("owner_uuid");
 
   /** Timestamp indicating when this group was created. */
-  public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
-      timestamp("created_on").build(InternalGroup::getCreatedOn);
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<InternalGroup, Timestamp> CREATED_ON_FIELD =
+      IndexedField.<InternalGroup>timestampBuilder("CreatedOn")
+          .required()
+          .build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
+
+  public static final IndexedField<InternalGroup, Timestamp>.SearchSpec CREATED_ON_SPEC =
+      CREATED_ON_FIELD.timestamp("created_on");
 
   /** Group name. */
-  public static final FieldDef<InternalGroup, String> NAME =
-      exact("name").build(InternalGroup::getName);
+  public static final IndexedField<InternalGroup, String> NAME_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Name")
+          .required()
+          .size(200)
+          .build(InternalGroup::getName);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
   /** Prefix match on group name parts. */
-  public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
+  public static final IndexedField<InternalGroup, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("NamePart")
+          .required()
+          .size(200)
+          .build(g -> SchemaUtil.getNameParts(g.getName()));
+
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
 
   /** Group description. */
-  public static final FieldDef<InternalGroup, String> DESCRIPTION =
-      fullText("description").build(InternalGroup::getDescription);
+  public static final IndexedField<InternalGroup, String> DESCRIPTION_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Description").build(InternalGroup::getDescription);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
   /** Whether the group is visible to all users. */
-  public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
-      exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+  public static final IndexedField<InternalGroup, String> IS_VISIBLE_TO_ALL_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("IsVisibleToAll")
+          .required()
+          .size(1)
+          .build(g -> g.isVisibleToAll() ? "1" : "0");
 
-  public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
-      integer("member")
-          .buildRepeatable(
-              g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+  public static final IndexedField<InternalGroup, String>.SearchSpec IS_VISIBLE_TO_ALL_SPEC =
+      IS_VISIBLE_TO_ALL_FIELD.exact("is_visible_to_all");
 
-  public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
-      exact("subgroup")
-          .buildRepeatable(
+  public static final IndexedField<InternalGroup, Iterable<Integer>> MEMBER_FIELD =
+      IndexedField.<InternalGroup>iterableIntegerBuilder("Member")
+          .build(g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+
+  public static final IndexedField<InternalGroup, Iterable<Integer>>.SearchSpec MEMBER_SPEC =
+      MEMBER_FIELD.integer("member");
+
+  public static final IndexedField<InternalGroup, Iterable<String>> SUBGROUP_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("Subgroup")
+          .build(
               g ->
                   g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
 
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec SUBGROUP_SPEC =
+      SUBGROUP_FIELD.exact("subgroup");
+
   /** ObjectId of HEAD:refs/groups/<UUID>. */
-  public static final FieldDef<InternalGroup, byte[]> REF_STATE =
-      storedOnly("ref_state")
+  public static final IndexedField<InternalGroup, byte[]> REF_STATE_FIELD =
+      IndexedField.<InternalGroup>byteArrayBuilder("RefState")
+          .stored()
+          .required()
           .build(
               g -> {
                 byte[] a = new byte[ObjectIds.STR_LEN];
                 MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
                 return a;
               });
+
+  public static final IndexedField<InternalGroup, byte[]>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index 28c0384..f6a9224 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.group.GroupPredicates;
+import java.util.function.Function;
 
 /**
  * Index for internal Gerrit groups. This class is mainly used for typing the generic parent class
@@ -33,4 +34,6 @@
   default Predicate<InternalGroup> keyPredicate(AccountGroup.UUID uuid) {
     return GroupPredicates.uuid(uuid);
   }
+
+  Function<InternalGroup, AccountGroup.UUID> ENTITY_TO_KEY = (g) -> g.getGroupUUID();
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index c4d8952..26f9e96 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -16,29 +16,48 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of group index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
-  static final Schema<InternalGroup> V2 =
+  static final Schema<InternalGroup> V5 =
       schema(
-          GroupField.DESCRIPTION,
-          GroupField.ID,
-          GroupField.IS_VISIBLE_TO_ALL,
-          GroupField.NAME,
-          GroupField.NAME_PART,
-          GroupField.OWNER_UUID,
-          GroupField.UUID);
-
-  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
-
-  @Deprecated
-  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
-
-  @Deprecated static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+          /* version= */ 5,
+          ImmutableList.of(),
+          ImmutableList.of(
+              GroupField.CREATED_ON_FIELD,
+              GroupField.DESCRIPTION_FIELD,
+              GroupField.ID_FIELD,
+              GroupField.IS_VISIBLE_TO_ALL_FIELD,
+              GroupField.MEMBER_FIELD,
+              GroupField.NAME_FIELD,
+              GroupField.NAME_PART_FIELD,
+              GroupField.OWNER_UUID_FIELD,
+              GroupField.REF_STATE_FIELD,
+              GroupField.SUBGROUP_FIELD,
+              GroupField.UUID_FIELD),
+          ImmutableList.<IndexedField<InternalGroup, ?>.SearchSpec>of(
+              GroupField.CREATED_ON_SPEC,
+              GroupField.DESCRIPTION_SPEC,
+              GroupField.ID_FIELD_SPEC,
+              GroupField.IS_VISIBLE_TO_ALL_SPEC,
+              GroupField.MEMBER_SPEC,
+              GroupField.NAME_SPEC,
+              GroupField.NAME_PART_SPEC,
+              GroupField.OWNER_UUID_SPEC,
+              GroupField.REF_STATE_SPEC,
+              GroupField.SUBGROUP_SPEC,
+              GroupField.UUID_FIELD_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
@@ -48,7 +67,10 @@
 
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range.
-  static final Schema<InternalGroup> V8 = schema(V7, false);
+  @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<InternalGroup> V9 = schema(V8);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   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 90070b6..7013e27 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -34,13 +34,13 @@
     implements DataSource<InternalGroup> {
 
   public static QueryOptions createOptions(
-      IndexConfig config, int start, int limit, Set<String> fields) {
+      IndexConfig config, int start, int pageSize, int limit, Set<String> fields) {
     // Always include GroupField.UUID since it is needed to load the group from NoteDb.
-    if (!fields.contains(GroupField.UUID.getName())) {
+    if (!fields.contains(GroupField.UUID_FIELD_SPEC.getName())) {
       fields = new HashSet<>(fields);
-      fields.add(GroupField.UUID.getName());
+      fields.add(GroupField.UUID_FIELD_SPEC.getName());
     }
-    return QueryOptions.create(config, start, limit, fields);
+    return QueryOptions.create(config, start, pageSize, limit, fields);
   }
 
   public IndexedGroupQuery(
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 4ce3f5b..72370bb 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -39,7 +39,7 @@
 @Singleton
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(GroupField.UUID.getName(), GroupField.REF_STATE.getName());
+      ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName(), GroupField.REF_STATE_SPEC.getName());
 
   private final GroupIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -66,7 +66,7 @@
     }
 
     Optional<FieldBundle> result =
-        i.getRaw(uuid, IndexedGroupQuery.createOptions(indexConfig, 0, 1, FIELDS));
+        i.getRaw(uuid, IndexedGroupQuery.createOptions(indexConfig, 0, 1, 1, FIELDS));
     if (!result.isPresent()) {
       // The document is missing in the index.
       try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -84,7 +84,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
       ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
-      ObjectId idFromIndex = ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0);
+      ObjectId idFromIndex =
+          ObjectId.fromString(result.get().getValue(GroupField.REF_STATE_SPEC), 0);
       if (head.equals(idFromIndex)) {
         return StalenessCheckResult.notStale();
       }
diff --git a/java/com/google/gerrit/server/index/options/AutoFlush.java b/java/com/google/gerrit/server/index/options/AutoFlush.java
new file mode 100644
index 0000000..7b82edb
--- /dev/null
+++ b/java/com/google/gerrit/server/index/options/AutoFlush.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.options;
+
+public enum AutoFlush {
+  ENABLED,
+  DISABLED
+}
diff --git a/java/com/google/gerrit/server/index/options/IsFirstInsertForEntry.java b/java/com/google/gerrit/server/index/options/IsFirstInsertForEntry.java
new file mode 100644
index 0000000..f943309
--- /dev/null
+++ b/java/com/google/gerrit/server/index/options/IsFirstInsertForEntry.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.options;
+
+/**
+ * This enum should be injected and checked to decide on which operation ({@link
+ * com.google.gerrit.index.Index#replace(Object) replace()} or {@link
+ * com.google.gerrit.index.Index#insert(Object) insert()}) should be performed on a specific {@link
+ * com.google.gerrit.index.Index index}.
+ */
+public enum IsFirstInsertForEntry {
+  YES,
+  NO
+}
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
index 0e4b688..1c977d1 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -48,12 +49,16 @@
 
   private final ListeningExecutorService executor;
   private final ProjectCache projectCache;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   @Inject
   AllProjectsIndexer(
-      @IndexExecutor(BATCH) ListeningExecutorService executor, ProjectCache projectCache) {
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      ProjectCache projectCache,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.projectCache = projectCache;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @Override
@@ -79,8 +84,13 @@
               () -> {
                 try {
                   projectCache.evict(name);
-                  index.replace(
-                      projectCache.get(name).orElseThrow(illegalState(name)).toProjectData());
+                  ProjectData projectData =
+                      projectCache.get(name).orElseThrow(illegalState(name)).toProjectData();
+                  if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+                    index.insert(projectData);
+                  } else {
+                    index.replace(projectData);
+                  }
                   verboseWriter.println("Reindexed " + desc);
                   done.incrementAndGet();
                 } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index 9c44c00..9f6bb31 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -40,7 +40,7 @@
  */
 public class StalenessChecker {
   private static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+      ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE.getName());
 
   private final ProjectCache projectCache;
   private final ProjectIndexCollection indexes;
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index fd0c4f1..6b9ecdf 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//lib:automaton",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index 296cf22..b43655a 100644
--- a/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -31,6 +31,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.CodedEnum;
 import java.io.EOFException;
 import java.io.IOException;
@@ -129,6 +130,7 @@
   }
 
   /** Read a UTF-8 string, prefixed by its byte length in a varint. */
+  @Nullable
   public static String readString(InputStream input) throws IOException {
     final byte[] bin = readBytes(input);
     if (bin.length == 0) {
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index ee0168c..7204c07 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -18,6 +18,5 @@
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 3907da5..eac96a6 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -90,8 +90,9 @@
     return tags.get() == null
         && forceLogging.get() == null
         && performanceLogging.get() == null
+        && (performanceLogRecords.get() == null || performanceLogRecords.get().isEmtpy())
         && aclLogging.get() == null
-        && aclLogRecords.get() == null;
+        && (aclLogRecords.get() == null || aclLogRecords.get().isEmpty());
   }
 
   public void clear() {
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 89b5b46..b433e9f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -102,6 +102,9 @@
   /** The name of a group. */
   public abstract Optional<String> groupName();
 
+  /** The group system being queried. */
+  public abstract Optional<String> groupSystem();
+
   /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
@@ -165,6 +168,8 @@
   /** The name of a REST view. */
   public abstract Optional<String> restViewName();
 
+  public abstract Optional<String> submitRequirementName();
+
   /** The SHA1 of Git commit. */
   public abstract Optional<String> revision();
 
@@ -326,6 +331,8 @@
 
     public abstract Builder groupName(@Nullable String groupName);
 
+    public abstract Builder groupSystem(@Nullable String groupSystem);
+
     public abstract Builder groupUuid(@Nullable String groupUuid);
 
     public abstract Builder httpStatus(int httpStatus);
@@ -375,6 +382,8 @@
 
     public abstract Builder revision(@Nullable String revision);
 
+    public abstract Builder submitRequirementName(@Nullable String srName);
+
     public abstract Builder username(@Nullable String username);
 
     public abstract Metadata build();
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
index baa9b1f..a692d2b 100644
--- a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -45,6 +45,10 @@
     return ImmutableList.copyOf(aclLogRecords);
   }
 
+  public boolean isEmpty() {
+    return aclLogRecords.isEmpty();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
diff --git a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
index 4ee70d7..2965719 100644
--- a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
+++ b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
@@ -46,6 +46,10 @@
     return ImmutableList.copyOf(performanceLogRecords);
   }
 
+  public boolean isEmtpy() {
+    return performanceLogRecords.isEmpty();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index 65e033b15..90e716f 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -56,7 +56,7 @@
     // Do not create performance log entries if performance logging is disabled or if no
     // PerformanceLogger is registered.
     boolean enablePerformanceLogging =
-        gerritConfig.getBoolean("tracing", "performanceLogging", true);
+        gerritConfig.getBoolean("tracing", "performanceLogging", false);
     LoggingContext.getInstance()
         .performanceLogging(
             enablePerformanceLogging && !Iterables.isEmpty(performanceLoggers.entries()));
diff --git a/java/com/google/gerrit/server/logging/RequestId.java b/java/com/google/gerrit/server/logging/RequestId.java
index 543f0a2..3ae9598 100644
--- a/java/com/google/gerrit/server/logging/RequestId.java
+++ b/java/com/google/gerrit/server/logging/RequestId.java
@@ -61,7 +61,7 @@
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
         (resourceId != null ? resourceId + "-" : "")
-            + TimeUtil.nowTs().getTime()
+            + TimeUtil.now().toEpochMilli()
             + "-"
             + h.hash().toString().substring(0, 8);
   }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0710784..2b8a501 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -85,7 +85,13 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** A service that can attach the comments from a {@link MailMessage} to a change. */
+/**
+ * Users can post comments on gerrit changes by replying directly to gerrit emails. This service
+ * parses the {@link MailMessage} sent by users and attaches the comments to a change.
+ *
+ * <p>This functionality can be configured or disabled by host. See {@link
+ * com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule}
+ */
 @Singleton
 public class MailProcessor {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -301,7 +307,9 @@
               .collect(ImmutableList.toImmutableList());
       CommentValidationContext commentValidationCtx =
           CommentValidationContext.create(
-              cd.change().getChangeId(), cd.change().getProject().get());
+              cd.change().getChangeId(),
+              cd.change().getProject().get(),
+              cd.change().getDest().branch());
       ImmutableList<CommentValidationFailure> commentValidationFailures =
           PublishCommentUtil.findInvalidComments(
               commentValidationCtx, commentValidators, parsedCommentsForValidation);
@@ -311,7 +319,7 @@
       }
 
       Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
-      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.nowTs());
+      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
       batchUpdate.addOp(cd.getId(), o);
       batchUpdate.execute();
     }
@@ -364,22 +372,18 @@
       // Send email notifications
       outgoingMailFactory
           .create(
-              ctx.getNotify(notes.getChangeId()),
-              notes,
+              ctx,
               patchSet,
-              ctx.getUser().asIdentifiedUser(),
+              notes.getMetaId(),
               mailMessage,
-              ctx.getWhen(),
               comments,
               patchSetComment,
-              ImmutableList.of(),
-              ctx.getRepoView())
+              ImmutableList.of())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
       approvalsUtil
-          .byPatchSetUser(
-              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
+          .byPatchSetUser(notes, psId, ctx.getAccountId())
           .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.
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 23e1cc3..a308168 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -131,27 +131,20 @@
       if (async) {
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError =
-            workQueue
-                .getDefaultQueue()
-                .submit(
-                    () -> {
-                      try {
-                        mailProcessor.process(m);
-                        requestDeletion(m.id());
-                      } catch (RestApiException | UpdateException e) {
-                        logger.atSevere().withCause(e).log(
-                            "Mail: Can't process message %s . Won't delete.", m.id());
-                      }
-                    });
+            workQueue.getDefaultQueue().submit(() -> processMessage(m));
       } else {
         // Synchronous processing is used only in tests.
-        try {
-          mailProcessor.process(m);
-          requestDeletion(m.id());
-        } catch (RestApiException | UpdateException e) {
-          logger.atSevere().withCause(e).log("Mail: Can't process messages. Won't delete.");
-        }
+        processMessage(m);
       }
     }
   }
+
+  private void processMessage(MailMessage m) {
+    try {
+      mailProcessor.process(m);
+      requestDeletion(m.id());
+    } catch (RestApiException | UpdateException e) {
+      logger.atSevere().withCause(e).log("Mail: Can't process message %s . Won't delete.", m.id());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index 3ac610d..d8b20ba 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ABANDONED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 652766a..6fe3cbe 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.IdentifiedUser;
@@ -111,10 +112,12 @@
     return "Unknown";
   }
 
+  @Nullable
   private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
+  @Nullable
   private String getGpgKeys() {
     if (gpgKeys != null) {
       return Joiner.on("\n").join(gpgKeys);
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
index b13bcf6..f9ef199 100644
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public AddToAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "addToAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
index 8f898a8..d1ee4ee 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -11,6 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.entities.Account;
@@ -23,8 +24,9 @@
   private Account.Id attentionSetUser;
   private String reason;
 
-  public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
-    super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+  public AttentionSetSender(
+      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
+    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
@@ -34,7 +36,6 @@
     ccAllApprovals();
     bccStarredBy();
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 
   public void setAttentionSetUser(Account.Id attentionSetUser) {
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 1a2e150..8be5548 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.base.Splitter;
@@ -24,7 +24,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeSizeBucket;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -42,6 +44,7 @@
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -51,17 +54,20 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
+import org.apache.http.client.utils.URIBuilder;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -72,6 +78,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class ChangeEmail extends NotificationEmail {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected static ChangeData newChangeData(
@@ -79,6 +86,11 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  protected static ChangeData newChangeData(
+      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
+  }
+
   private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
@@ -86,7 +98,7 @@
   protected PatchSet patchSet;
   protected PatchSetInfo patchSetInfo;
   protected String changeMessage;
-  protected Timestamp timestamp;
+  protected Instant timestamp;
 
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
@@ -96,17 +108,17 @@
   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
     super(args, messageClass, changeData.change().getDest());
     this.changeData = changeData;
-    this.change = changeData.change();
-    this.emailOnlyAuthors = false;
-    this.emailOnlyAttentionSetIfEnabled = true;
-    this.currentAttentionSet = getAttentionSet();
+    change = changeData.change();
+    emailOnlyAuthors = false;
+    emailOnlyAttentionSetIfEnabled = true;
+    currentAttentionSet = getAttentionSet();
   }
 
   @Override
   public void setFrom(Account.Id id) {
     super.setFrom(id);
 
-    /** Is the from user in an email squelching group? */
+    // Is the from user in an email squelching group?
     try {
       args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
     } catch (AuthException | PermissionBackendException e) {
@@ -123,7 +135,7 @@
     patchSetInfo = psi;
   }
 
-  public void setChangeMessage(String cm, Timestamp t) {
+  public void setChangeMessage(String cm, Instant t) {
     changeMessage = cm;
     timestamp = t;
   }
@@ -190,7 +202,7 @@
 
     super.init();
     if (timestamp != null) {
-      setHeader(FieldName.DATE, new Date(timestamp.getTime()));
+      setHeader(FieldName.DATE, timestamp);
     }
     setChangeSubjectHeader();
     setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
@@ -229,22 +241,43 @@
     setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
   }
 
-  /** Get a link to the change; null if the server doesn't know its own address. */
+  private int getInsertionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::insertions)
+        .reduce(0, Integer::sum);
+  }
+
+  private int getDeletionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::deletions)
+        .reduce(0, Integer::sum);
+  }
+
+  /**
+   * Get a link to the change; null if the server doesn't know its own address or if the address is
+   * malformed. The link will contain a usp parameter set to "email" to inform the frontend on
+   * clickthroughs where the link came from.
+   */
   @Nullable
   public String getChangeUrl() {
-    return args.urlFormatter
-        .get()
-        .getChangeViewUrl(change.getProject(), change.getId())
-        .orElse(null);
+    Optional<String> changeUrl =
+        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
+    if (!changeUrl.isPresent()) return null;
+    try {
+      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
   }
 
   public String getChangeMessageThreadId() {
     return "<gerrit."
-        + change.getCreatedOn().getTime()
+        + change.getCreatedOn().toEpochMilli()
         + "."
         + change.getKey().get()
         + "@"
-        + this.getGerritHost()
+        + getGerritHost()
         + ">";
   }
 
@@ -269,7 +302,8 @@
 
       if (patchSet != null) {
         detail.append("---\n");
-        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles();
+        // Sort files by name.
+        TreeMap<String, FileDiffOutput> modifiedFiles = new TreeMap<>(listModifiedFiles());
         for (FileDiffOutput fileDiff : modifiedFiles.values()) {
           if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
             continue;
@@ -282,10 +316,6 @@
                       fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
               .append("\n");
         }
-        Integer insertions =
-            modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
-        Integer deletions =
-            modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
         detail.append(
             MessageFormat.format(
                 "" //
@@ -294,8 +324,8 @@
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
                 modifiedFiles.size() - 1, //
-                insertions, //
-                deletions));
+                getInsertionsCount(), //
+                getDeletionsCount()));
         detail.append("\n");
       }
       return detail.toString();
@@ -306,29 +336,35 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId)
-      throws DiffNotAvailableException {
-    PatchSet ps;
-    if (patchSetId == patchSet.number()) {
-      ps = patchSet;
-    } else {
-      try {
+  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
+    try {
+      PatchSet ps;
+      if (patchSetId == patchSet.number()) {
+        ps = patchSet;
+      } else {
         ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
-      } catch (StorageException e) {
-        throw new DiffNotAvailableException("Failed to get patchSet", e);
       }
+      return args.diffOperations.listModifiedFilesAgainstParent(
+          change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+    } catch (StorageException | DiffNotAvailableException e) {
+      logger.atSevere().withCause(e).log("Failed to get modified files");
+      return new HashMap<>();
     }
-    return args.diffOperations.listModifiedFilesAgainstParent(
-        change.getProject(), ps.commitId(), /* parentNum= */ 0);
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected Map<String, FileDiffOutput> listModifiedFiles() throws DiffNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles() {
     if (patchSet != null) {
-      return args.diffOperations.listModifiedFilesAgainstParent(
-          change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+      try {
+        return args.diffOperations.listModifiedFilesAgainstParent(
+            change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+      } catch (DiffNotAvailableException e) {
+        logger.atSevere().withCause(e).log("Failed to get modified files");
+      }
+    } else {
+      logger.atSevere().log("no patchSet specified");
     }
-    throw new DiffNotAvailableException("no patchSet specified");
+    return new HashMap<>();
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
@@ -356,14 +392,6 @@
     }
   }
 
-  protected void removeUsersThatIgnoredTheChange() {
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
-        args.accountCache.get(e.getKey()).ifPresent(a -> removeUser(a.account()));
-      }
-    }
-  }
-
   @Override
   protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     if (!NotifyHandling.ALL.equals(notify.handling())) {
@@ -408,15 +436,25 @@
 
   @Override
   protected void add(RecipientType rt, Account.Id to) {
-    Optional<AccountState> accountState = args.accountCache.get(to);
-    if (!accountState.isPresent()) {
-      return;
-    }
-    if (emailOnlyAttentionSetIfEnabled
-        && accountState.get().generalPreferences().getEmailStrategy()
-            == EmailStrategy.ATTENTION_SET_ONLY
-        && !currentAttentionSet.contains(to)) {
-      return;
+    addRecipient(rt, to, /* isWatcher= */ false);
+  }
+
+  /** This bypasses the EmailStrategy.ATTENTION_SET_ONLY strategy when adding the recipient. */
+  @Override
+  protected void addWatcher(RecipientType rt, Account.Id to) {
+    addRecipient(rt, to, /* isWatcher= */ true);
+  }
+
+  private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
+    if (!isWatcher) {
+      Optional<AccountState> accountState = args.accountCache.get(to);
+      if (emailOnlyAttentionSetIfEnabled
+          && accountState.isPresent()
+          && accountState.get().generalPreferences().getEmailStrategy()
+              == EmailStrategy.ATTENTION_SET_ONLY
+          && !currentAttentionSet.contains(to)) {
+        return;
+      }
     }
     if (emailOnlyAuthors && !authors.contains(to)) {
       return;
@@ -429,12 +467,7 @@
     if (!projectState.statePermitsRead()) {
       return false;
     }
-    try {
-      args.permissionBackend.absentUser(to).change(changeData).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
   /** Find all users who are authors of any part of this change. */
@@ -494,6 +527,9 @@
     changeData.put("ownerName", getNameFor(change.getOwner()));
     changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
+    changeData.put(
+        "sizeBucket",
+        ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
     soyContext.put("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
@@ -507,7 +543,7 @@
     soyContext.put("patchSetInfo", patchSetInfoData);
 
     footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
+    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
     footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     if (change.getAssignee() != null) {
@@ -527,7 +563,7 @@
       // We need names rather than account ids / emails to make it user readable.
       soyContext.put(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
+          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
     }
   }
 
@@ -559,7 +595,7 @@
     try {
       attentionSet =
           additionsOnly(changeData.attentionSet()).stream()
-              .map(a -> a.account())
+              .map(AttentionSetUpdate::account)
               .collect(Collectors.toSet());
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change attention set");
@@ -576,16 +612,11 @@
   /** Show patch set as unified difference. */
   public String getUnifiedDiff() {
     Map<String, FileDiffOutput> modifiedFiles;
-    try {
-      modifiedFiles = listModifiedFiles();
-      if (modifiedFiles.isEmpty()) {
-        // Octopus merges are not well supported for diff output by Gerrit.
-        // Currently these always have a null oldId in the PatchList.
-        return "[Octopus merge; cannot be formatted as a diff.]\n";
-      }
-    } catch (DiffNotAvailableException e) {
-      logger.atSevere().withCause(e).log("Cannot format patch");
-      return "";
+    modifiedFiles = listModifiedFiles();
+    if (modifiedFiles.isEmpty()) {
+      // Octopus merges are not well supported for diff output by Gerrit.
+      // Currently these always have a null oldId in the PatchList.
+      return "[Empty change (potentially Octopus merge); cannot be formatted as a diff.]\n";
     }
 
     int maxSize = args.settings.maximumDiffSize;
@@ -595,6 +626,11 @@
         try {
           ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
           ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
+          if (oldId.equals(ObjectId.zeroId())) {
+            // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
+            // parents.
+            oldId = null;
+          }
           fmt.setRepository(git);
           fmt.setDetectRenames(true);
           fmt.format(oldId, newId);
@@ -621,7 +657,8 @@
    * @param sourceDiff the unified diff that we're converting to the map.
    * @return map of 'type' to a line's content.
    */
-  protected ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(String sourceDiff) {
+  protected static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(
+      String sourceDiff) {
     ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
     for (String diffLine : lineSplitter.split(sourceDiff)) {
diff --git a/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
index 2590505..71f4a90 100644
--- a/java/com/google/gerrit/server/mail/send/CommentFormatter.java
+++ b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
@@ -17,9 +17,9 @@
 import static com.google.common.base.Strings.isNullOrEmpty;
 
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 public class CommentFormatter {
@@ -47,12 +47,12 @@
    * @param source The raw, unescaped comment in the Gerrit wiki-like format.
    * @return List of block objects, each with unescaped comment content.
    */
-  public static List<Block> parse(@Nullable String source) {
+  public static ImmutableList<Block> parse(@Nullable String source) {
     if (isNullOrEmpty(source)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<Block> result = new ArrayList<>();
+    ImmutableList.Builder<Block> result = ImmutableList.builder();
     for (String p : Splitter.on("\n\n").split(source)) {
       if (isQuote(p)) {
         result.add(makeQuote(p));
@@ -64,7 +64,7 @@
         result.add(makeParagraph(p));
       }
     }
-    return result;
+    return result.build();
   }
 
   /**
@@ -91,7 +91,7 @@
    * @param p The block containing the list (as well as potential paragraphs).
    * @param out The list of blocks to append to.
    */
-  private static void makeList(String p, List<Block> out) {
+  private static void makeList(String p, ImmutableList.Builder<Block> out) {
     Block block = null;
     StringBuilder textBuilder = null;
     boolean inList = false;
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 5a7352a..79696fe 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.FilenameComparator;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -28,6 +33,8 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
@@ -37,7 +44,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
@@ -56,33 +62,44 @@
 import java.util.Optional;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
 public class CommentSender extends ReplyToChangeSender {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id changeId);
+
+    CommentSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private class FileCommentGroup {
+
     public String filename;
     public int patchSetId;
     public PatchFile fileData;
     public List<Comment> comments = new ArrayList<>();
 
     /** Returns a web link to a comment for a change. */
+    @Nullable
     public String getCommentLink(String uuid) {
       return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
     }
 
     /** Returns a web link to the comment tab view of a change. */
+    @Nullable
     public String getCommentsTabLink() {
       return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
     }
 
     /** Returns a web link to the findings tab view of a change. */
+    @Nullable
     public String getFindingsTabLink() {
       return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
     }
@@ -104,11 +121,14 @@
   }
 
   private List<? extends Comment> inlineComments = Collections.emptyList();
-  private String patchSetComment;
-  private List<LabelVote> labels = Collections.emptyList();
+  @Nullable private String patchSetComment;
+  private ImmutableList<LabelVote> labels = ImmutableList.of();
   private final CommentsUtil commentsUtil;
   private final boolean incomingEmailEnabled;
   private final String replyToAddress;
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public CommentSender(
@@ -116,24 +136,35 @@
       CommentsUtil commentsUtil,
       @GerritServerConfig Config cfg,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      @Assisted Change.Id changeId,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "comment", newChangeData(args, project, changeId));
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
             > Protocol.NONE.ordinal();
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailReviewComments.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
-  public void setPatchSetComment(String comment) {
+  public void setPatchSetComment(@Nullable String comment) {
     this.patchSetComment = comment;
   }
 
-  public void setLabels(List<LabelVote> labels) {
+  public void setLabels(ImmutableList<LabelVote> labels) {
     this.labels = labels;
   }
 
@@ -148,7 +179,6 @@
       bccStarredBy();
       includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     }
-    removeUsersThatIgnoredTheChange();
 
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
@@ -199,12 +229,7 @@
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
         // Get the modified files:
-        Map<String, FileDiffOutput> modifiedFiles = null;
-        try {
-          modifiedFiles = listModifiedFiles(c.key.patchSetId);
-        } catch (DiffNotAvailableException e) {
-          logger.atSevere().withCause(e).log("Failed to get modified files");
-        }
+        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
 
         groups.add(currentGroup);
         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
@@ -483,6 +508,7 @@
     return false;
   }
 
+  @Nullable
   private Repository getRepository() {
     try {
       return args.server.openRepository(projectState.getNameKey());
@@ -509,6 +535,15 @@
     soyContext.put(
         "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
 
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+
     footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
     footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
     footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
@@ -518,6 +553,59 @@
     }
   }
 
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
+  }
+
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
     try {
       return fileInfo.getLine(side, lineNbr);
@@ -538,8 +626,8 @@
     }
   }
 
-  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
-    List<Map<String, Object>> result = new ArrayList<>();
+  private ImmutableList<Map<String, Object>> getLabelVoteSoyData(ImmutableList<LabelVote> votes) {
+    ImmutableList.Builder<Map<String, Object>> result = ImmutableList.builder();
     for (LabelVote vote : votes) {
       Map<String, Object> data = new HashMap<>();
       data.put("label", vote.label());
@@ -549,12 +637,12 @@
       data.put("value", (int) vote.value());
       result.add(data);
     }
-    return result;
+    return result.build();
   }
 
   private String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
     return MailProcessingUtil.rfcDateformatter.format(
-        ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
+        ZonedDateTime.ofInstant(timestamp, 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 b78dc62..e327d4d 100644
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -14,71 +14,30 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-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.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.stream.StreamSupport;
 
 /** Notify interested parties of a brand new change. */
 public class CreateChangeSender extends NewChangeSender {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     CreateChangeSender create(Project.NameKey project, Change.Id changeId);
   }
 
-  private final PermissionBackend permissionBackend;
-
   @Inject
   public CreateChangeSender(
-      EmailArguments args,
-      PermissionBackend permissionBackend,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
     super(args, newChangeData(args, project, changeId));
-    this.permissionBackend = permissionBackend;
   }
 
   @Override
   protected void init() throws EmailException {
     super.init();
 
-    try {
-      // Upgrade watching owners from CC and BCC to TO.
-      Watchers matching =
-          getWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
-      // TODO(hiesel): Remove special handling for owners
-      StreamSupport.stream(matching.all().accounts.spliterator(), false)
-          .filter(this::isOwnerOfProjectOrBranch)
-          .forEach(acc -> add(RecipientType.TO, acc));
-      // Add everyone else. Owners added above will not be duplicated.
-      add(RecipientType.TO, matching.to);
-      add(RecipientType.CC, matching.cc);
-      add(RecipientType.BCC, matching.bcc);
-    } 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.
-      logger.atWarning().withCause(err).log("Cannot notify watchers for new change");
-    }
-
+    includeWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
   }
-
-  private boolean isOwnerOfProjectOrBranch(Account.Id userId) {
-    return permissionBackend
-        .absentUser(userId)
-        .ref(change.getDest())
-        .testOrFalse(RefPermission.WRITE_CONFIG);
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index d6d306c..64a01ff 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.IdentifiedUser;
@@ -109,10 +110,12 @@
     throw new IllegalStateException("key type is not SSH or GPG");
   }
 
+  @Nullable
   private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
+  @Nullable
   private String getGpgKeyFingerprints() {
     if (!gpgKeyFingerprints.isEmpty()) {
       return Joiner.on("\n").join(gpgKeyFingerprints);
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 0de0dbe..bd79d3a 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
@@ -63,7 +64,6 @@
     includeWatchers(NotifyType.ALL_COMMENTS);
     reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
@@ -74,6 +74,7 @@
     }
   }
 
+  @Nullable
   public List<String> getReviewerNames() {
     if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 77efbf8..f71cc00 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index ad1703d..8ee8fc2 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.io.CharStreams;
-import com.google.common.io.Resources;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
@@ -26,6 +27,7 @@
 import com.google.template.soy.shared.SoyAstCache;
 import java.io.IOException;
 import java.io.Reader;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -137,6 +139,8 @@
     }
 
     // Otherwise load the template as a resource.
-    builder.add(Resources.getResource(logicalPath), logicalPath);
+    URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+    checkArgument(resource != null, "resource %s not found.", logicalPath);
+    builder.add(resource, logicalPath);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
index a3cf3e3..25b2ebd 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.mail.send;
 
 import static com.google.common.base.Preconditions.checkArgument;
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cec857d..9717f0e 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -26,12 +29,16 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     MergedSender create(
         Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
@@ -52,6 +59,22 @@
   }
 
   @Override
+  public void setNotify(NotifyResolver.Result notify) {
+    checkNotNull(notify);
+    if (!stickyApprovalDiff.isEmpty()) {
+      if (notify.handling() != NotifyHandling.ALL) {
+        logger.atFine().log(
+            "Requested to notify %s, but for change submission with sticky approval diff,"
+                + " Notify=ALL is enforced.",
+            notify.handling().name());
+      }
+      this.notify = NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts());
+    } else {
+      this.notify = notify;
+    }
+  }
+
+  @Override
   protected void init() throws EmailException {
     // We want to send the submit email even if the "send only when in attention set" is enabled.
     emailOnlyAttentionSetIfEnabled = false;
@@ -62,7 +85,6 @@
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
     includeWatchers(NotifyType.SUBMITTED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
index b187f9c..dcf3b6c 100644
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
@@ -37,6 +37,5 @@
     super.init();
 
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 001de52..968bb1a 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
@@ -96,7 +97,8 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  @Nullable
+  private List<String> getReviewerNames() {
     if (reviewers.isEmpty()) {
       return null;
     }
@@ -107,7 +109,8 @@
     return names;
   }
 
-  public List<String> getRemovedReviewerNames() {
+  @Nullable
+  private List<String> getRemovedReviewerNames() {
     if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
       return null;
     }
@@ -116,7 +119,7 @@
       names.add(getNameFor(id));
     }
     for (Address address : removedByEmailReviewers) {
-      names.add(address.name());
+      names.add(address.toString());
     }
     return names;
   }
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 5ffd928..f023075 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -17,6 +17,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
@@ -26,6 +27,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -80,15 +82,18 @@
   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) {
-    for (Account.Id user : list.accounts) {
-      add(type, user);
+  protected void add(RecipientType type, WatcherList watcherList) {
+    for (Account.Id user : watcherList.accounts) {
+      addWatcher(type, user);
     }
-    for (Address addr : list.emails) {
+    for (Address addr : watcherList.emails) {
       add(type, addr);
     }
   }
 
+  protected abstract void addWatcher(RecipientType type, Account.Id to);
+
+  @Nullable
   public String getSshHost() {
     String host = Iterables.getFirst(args.sshAddresses, null);
     if (host == null) {
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index effeaea..55f82d4 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -42,9 +42,9 @@
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -67,6 +67,7 @@
   private final Set<Account.Id> rcptTo = new HashSet<>();
   private final Map<String, EmailHeader> headers;
   private final Set<Address> smtpRcptTo = new HashSet<>();
+  private final Set<Address> smtpBccRcptTo = new HashSet<>();
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
@@ -228,8 +229,13 @@
             j.add(address.email());
           }
         }
-        smtpRcptTo.stream().forEach(a -> j.add(a.email()));
-        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.email()));
+        // For users who prefer plaintext, this comes at the cost of not being
+        // listed in the multipart To and Cc headers. We work around this by adding
+        // all users to the Reply-To address in both the plaintext and multipart
+        // email. We should exclude any BCC addresses from reply-to, because they should be
+        // invisible to other recipients.
+        Sets.difference(Sets.union(smtpRcptTo, smtpRcptToPlaintextOnly), smtpBccRcptTo).stream()
+            .forEach(a -> j.add(a.email()));
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
@@ -318,7 +324,7 @@
     setupSoyContext();
 
     smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
-    setHeader(FieldName.DATE, new Date());
+    setHeader(FieldName.DATE, Instant.now());
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
     headers.put(FieldName.CC, new EmailHeader.AddressList());
@@ -374,10 +380,12 @@
     return SystemReader.getInstance().getHostname();
   }
 
+  @Nullable
   public String getSettingsUrl() {
     return args.urlFormatter.get().getSettingsUrl().orElse(null);
   }
 
+  @Nullable
   private String getGerritUrl() {
     return args.urlFormatter.get().getWebUrl().orElse(null);
   }
@@ -392,7 +400,7 @@
     headers.remove(name);
   }
 
-  protected void setHeader(String name, Date date) {
+  protected void setHeader(String name, Instant date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
@@ -463,9 +471,14 @@
    * username. If no username is set, this function returns null.
    *
    * @param accountId user to fetch.
-   * @return name/email of account, username, or null if unset.
+   * @return name/email of account, username, or null if unset or the accountId is null.
    */
-  protected String getUserNameEmailFor(Account.Id accountId) {
+  @Nullable
+  protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
+    if (accountId == null) {
+      return null;
+    }
+
     Optional<AccountState> accountState = args.accountCache.get(accountId);
     if (!accountState.isPresent()) {
       return null;
@@ -544,6 +557,8 @@
    * Returns whether this email is visible to the given account
    *
    * @param to account.
+   * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
+   *     permission backend
    */
   protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
     return true;
@@ -565,6 +580,7 @@
           }
           ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
           ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
+          smtpBccRcptTo.remove(addr);
         }
         switch (rt) {
           case TO:
@@ -574,12 +590,14 @@
             ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
+            smtpBccRcptTo.add(addr);
             break;
         }
       }
     }
   }
 
+  @Nullable
   private Address toAddress(Account.Id id) {
     Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account);
     if (!accountState.isPresent()) {
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 173b121..cbf47c5 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -24,21 +25,25 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
+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.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
 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.GroupBackedUser;
+import java.io.IOException;
 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;
 
 public class ProjectWatch {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -111,13 +116,13 @@
   }
 
   public static class Watchers {
-    static class List {
+    static class WatcherList {
       protected final Set<Account.Id> accounts = new HashSet<>();
       protected final Set<Address> emails = new HashSet<>();
 
-      private static List union(List... others) {
-        List union = new List();
-        for (List other : others) {
+      private static WatcherList union(WatcherList... others) {
+        WatcherList union = new WatcherList();
+        for (WatcherList other : others) {
           union.accounts.addAll(other.accounts);
           union.emails.addAll(other.emails);
         }
@@ -125,15 +130,15 @@
       }
     }
 
-    protected final List to = new List();
-    protected final List cc = new List();
-    protected final List bcc = new List();
+    protected final WatcherList to = new WatcherList();
+    protected final WatcherList cc = new WatcherList();
+    protected final WatcherList bcc = new WatcherList();
 
-    List all() {
-      return List.union(to, cc, bcc);
+    WatcherList all() {
+      return WatcherList.union(to, cc, bcc);
     }
 
-    List list(NotifyConfig.Header header) {
+    WatcherList list(NotifyConfig.Header header) {
       switch (header) {
         case TO:
           return to;
@@ -171,7 +176,7 @@
     }
   }
 
-  private void deliverToMembers(Watchers.List matching, AccountGroup.UUID startUUID) {
+  private void deliverToMembers(WatcherList matching, AccountGroup.UUID startUUID) {
     Set<AccountGroup.UUID> seen = new HashSet<>();
     List<AccountGroup.UUID> q = new ArrayList<>();
 
@@ -239,15 +244,16 @@
   }
 
   private boolean filterMatch(CurrentUser user, String filter) throws QueryParseException {
-    ChangeQueryBuilder qb;
+    WatcherChangeQueryBuilder qb;
     Predicate<ChangeData> p = null;
 
     if (user == null) {
-      qb = args.queryBuilder.get().asUser(args.anonymousUser.get());
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), args.anonymousUser.get());
     } else {
-      qb = args.queryBuilder.get().asUser(user);
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), user);
       p = qb.isVisible();
     }
+    qb.forceAccountVisibilityCheck();
 
     if (filter != null) {
       Predicate<ChangeData> filterPredicate = qb.parse(filter);
@@ -259,4 +265,40 @@
     }
     return p == null || p.asMatchable().match(changeData);
   }
+
+  private static class WatcherChangeQueryBuilder extends ChangeQueryBuilder {
+    private WatcherChangeQueryBuilder(Arguments args) {
+      super(args);
+    }
+
+    public static WatcherChangeQueryBuilder asUser(ChangeQueryBuilder other, CurrentUser user) {
+      return new WatcherChangeQueryBuilder(other.getArgs().asUser(user));
+    }
+
+    @Override
+    protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
+      if (query.startsWith("refs/")) {
+        return ref(query);
+      }
+
+      // Adapt the capacity of this list when adding more default predicates.
+      List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
+      predicates.add(file(query));
+      try {
+        predicates.add(label(query));
+      } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
+        // Skip.
+      }
+      predicates.add(commit(query));
+      predicates.add(message(query));
+      predicates.add(comment(query));
+      predicates.add(projects(query));
+      predicates.add(ref(query));
+      predicates.add(branch(query));
+      predicates.add(topic(query));
+      // Adapt the capacity of the "predicates" list when adding more default
+      // predicates.
+      return Predicate.or(predicates);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
index 6762b7d..5242bfb 100644
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public RemoveFromAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "removeFromAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 9516b9f..5f31c68 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,34 +14,89 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 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.extensions.client.ChangeKind;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id changeId);
+    ReplacePatchSetSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final ChangeKind changeKind;
+  private final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public ReplacePatchSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "newpatchset", newChangeData(args, project, changeId));
+    this.changeKind = changeKind;
+
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailNewPatchSet.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    if (!isChangeNoLongerSubmittable() && changeKind.isTrivialRebase()) {
+      logger.atFine().log(
+          "skip email because new patch set is a trivial rebase that didn't make the change"
+              + " non-submittable");
+      return false;
+    }
+
+    return super.shouldSendMessage();
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -52,6 +107,12 @@
     extraCC.addAll(cc);
   }
 
+  public void addOutdatedApproval(@Nullable Collection<PatchSetApproval> outdatedApprovals) {
+    if (outdatedApprovals != null) {
+      this.outdatedApprovals.addAll(outdatedApprovals);
+    }
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -71,7 +132,6 @@
     }
     bccStarredBy();
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
@@ -82,7 +142,8 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  @Nullable
+  public ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       if (id.equals(fromId)) {
@@ -93,12 +154,87 @@
     if (names.isEmpty()) {
       return null;
     }
-    return names;
+    return names.stream().sorted().collect(toImmutableList());
+  }
+
+  private ImmutableList<String> formatOutdatedApprovals() {
+    return outdatedApprovals.stream()
+        .map(
+            outdatedApproval ->
+                String.format(
+                    "%s by %s",
+                    LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
+                    getNameFor(outdatedApproval.accountId())))
+        .sorted()
+        .collect(toImmutableList());
   }
 
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("outdatedApprovals", formatOutdatedApprovals());
+
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+  }
+
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index ffe70cf..e37d8f9 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index c11529b..1d7223d 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -40,7 +40,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index d32e6fb..c06cc1e 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -36,10 +36,11 @@
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.Writer;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Date;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -281,9 +282,11 @@
       setMissingHeader(hdrs, "Importance", importance);
     }
     if (expiryDays > 0) {
-      Date expiry = new Date(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
-      setMissingHeader(
-          hdrs, "Expiry-Date", new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z").format(expiry));
+      Instant expiry = Instant.ofEpochMilli(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
+      DateTimeFormatter fmt =
+          DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z")
+              .withZone(ZoneId.systemDefault());
+      setMissingHeader(hdrs, "Expiry-Date", fmt.format(expiry));
     }
 
     String encodedBody;
@@ -382,7 +385,7 @@
     try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
       qp.write(input.getBytes(UTF_8));
     }
-    return s.toString();
+    return s.toString(UTF_8);
   }
 
   private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index d71f9ff..2edea26 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -24,6 +25,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
@@ -37,8 +39,6 @@
 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;
 
 /** View of contents at a single ref related to some change. * */
 public abstract class AbstractChangeNotes<T> {
@@ -53,6 +53,7 @@
     public final AllUsersName allUsers;
     public final NoteDbMetrics metrics;
     public final String serverId;
+    public final ImmutableSet<String> importedServerIds;
 
     // Providers required to avoid dependency cycles.
 
@@ -66,7 +67,8 @@
         ChangeNoteJson changeNoteJson,
         NoteDbMetrics metrics,
         Provider<ChangeNotesCache> cache,
-        @GerritServerId String serverId) {
+        @GerritServerId String serverId,
+        @GerritImportedServerIds ImmutableSet<String> importedServerIds) {
       this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
       this.allUsers = allUsers;
@@ -74,6 +76,7 @@
       this.metrics = metrics;
       this.cache = cache;
       this.serverId = serverId;
+      this.importedServerIds = importedServerIds;
     }
   }
 
@@ -140,6 +143,15 @@
   }
 
   public T load() {
+    try (Repository repo = args.repoManager.openRepository(getProjectName())) {
+      load(repo);
+      return self();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  public T load(Repository repo) {
     if (loaded) {
       return self();
     }
@@ -148,7 +160,6 @@
       throw new StorageException("Reading from NoteDb is disabled");
     }
     try (Timer0.Context timer = args.metrics.readLatency.start();
-        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, revision)) {
@@ -173,17 +184,16 @@
    * <p>Implementations may override this method to provide auto-rebuilding behavior.
    *
    * @param repo open repository.
+   * @param id SHA1 of the entity to read from the repository. The SHA1 is not sanity checked and is
+   *     assumed to be valid. If null, lookup SHA1 from the /meta ref.
    * @return handle for reading the entity.
    * @throws NoSuchChangeException change does not exist.
-   * @throws MissingMetaObjectException specified SHA1 isn't reachable from meta branch.
    * @throws IOException a repo-level error occurred.
    */
   protected LoadHandle openHandle(Repository repo, @Nullable ObjectId id)
-      throws NoSuchChangeException, IOException, MissingMetaObjectException {
+      throws NoSuchChangeException, IOException {
     if (id == null) {
       id = readRef(repo);
-    } else {
-      verifyMetaId(repo, id);
     }
 
     return new LoadHandle(repo, id);
@@ -194,6 +204,7 @@
     return load();
   }
 
+  @Nullable
   public ObjectId loadRevision() {
     if (loaded) {
       return getRevision();
@@ -226,20 +237,4 @@
   protected final T self() {
     return (T) this;
   }
-
-  private void verifyMetaId(Repository repo, ObjectId id)
-      throws IOException, MissingMetaObjectException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      Ref ref = repo.getRefDatabase().exactRef(getRefName());
-      RevCommit tip = rw.parseCommit(ref.getObjectId());
-      rw.markStart(tip);
-      for (RevCommit rev : rw) {
-        if (id.equals(rev)) {
-          return;
-        }
-      }
-    }
-
-    throw new MissingMetaObjectException(id.getName() + " not reachable from " + getRefName());
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 6677490..ba91c68 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,7 +45,7 @@
   protected final Account.Id accountId;
   protected final Account.Id realAccountId;
   protected final PersonIdent authorIdent;
-  protected final Date when;
+  protected final Instant when;
 
   @Nullable private final ChangeNotes notes;
   private final Change change;
@@ -60,7 +60,7 @@
       CurrentUser user,
       PersonIdent serverIdent,
       ChangeNoteUtil noteUtil,
-      Date when) {
+      Instant when) {
     this.noteUtil = noteUtil;
     this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
@@ -80,7 +80,7 @@
       Account.Id accountId,
       Account.Id realAccountId,
       PersonIdent authorIdent,
-      Date when) {
+      Instant when) {
     checkArgument(
         (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
@@ -101,13 +101,14 @@
         user);
   }
 
+  @Nullable
   private static Account.Id accountId(CurrentUser u) {
     checkUserType(u);
     return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
   }
 
   private static PersonIdent ident(
-      ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
+      ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Instant when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
       return noteUtil.newAccountIdIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
@@ -137,7 +138,7 @@
     return change;
   }
 
-  public Date getWhen() {
+  public Instant getWhen() {
     return when;
   }
 
@@ -206,6 +207,7 @@
    *     deleted.
    * @throws IOException if a lower-level error occurred.
    */
+  @Nullable
   final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 57f6353..0dcf786 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -19,6 +19,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
@@ -31,9 +32,9 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -60,14 +61,14 @@
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
 
     ChangeDraftUpdate create(
         Change change,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
   }
 
   @AutoValue
@@ -92,6 +93,7 @@
   private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeDraftUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -101,7 +103,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
@@ -115,7 +117,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
@@ -186,6 +188,7 @@
     return clonedUpdate;
   }
 
+  @Nullable
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
new file mode 100644
index 0000000..771d72b
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 org.eclipse.jgit.revwalk.FooterKey;
+
+/** Footers, that can be set in NoteDb commits. */
+public class ChangeNoteFooters {
+  public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
+  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
+  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  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_REAL_USER = new FooterKey("Real-user");
+  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
+  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
+  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
+  public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 4c41a12..de401ac 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -16,9 +16,16 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
+import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
+import com.google.gerrit.json.OptionalTypeAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
 import com.google.gson.TypeAdapter;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
@@ -26,19 +33,43 @@
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
+/**
+ * Provides {@link Gson} to parse {@link ChangeRevisionNote}, attached to the change update.
+ *
+ * <p>Apart from the adapters for the custom JSON format, this class also registers adapters that
+ * support forward/backward compatibility when modifying {@link ChangeNotes} storage format.
+ *
+ * <p>NOTE: All changes to the storage format must be both forward and backward compatible, see
+ * comment on {@link ChangeNotesParser}.
+ *
+ * <p>For JSON, such changes include e.g. modifications to the serialized {@code AutoValue} classes.
+ */
 @Singleton
 public class ChangeNoteJson {
   private final Gson gson = newGson();
 
   static Gson newGson() {
     return new GsonBuilder()
+        .registerTypeAdapter(Optional.class, new OptionalTypeAdapter())
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
         .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
         .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .registerTypeAdapter(
             new TypeLiteral<ImmutableList<String>>() {}.getType(),
             new ImmutableListAdapter().nullSafe())
+        .registerTypeAdapter(
+            new TypeLiteral<Optional<Boolean>>() {}.getType(),
+            new OptionalBooleanAdapter().nullSafe())
+        .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
+        .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
+        .registerTypeAdapter(
+            SubmitRequirementExpressionResult.Status.class,
+            new SubmitRequirementExpressionResultStatusAdapter())
         .setPrettyPrinting()
         .create();
   }
@@ -47,6 +78,69 @@
     return gson;
   }
 
+  static class OptionalBooleanAdapter extends TypeAdapter<Optional<Boolean>> {
+    @Override
+    public void write(JsonWriter out, Optional<Boolean> value) throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.beginObject();
+      out.name("value");
+      if (value.isPresent()) {
+        out.value(value.get());
+      } else {
+        out.nullValue();
+      }
+      out.endObject();
+    }
+
+    @Override
+    public Optional<Boolean> read(JsonReader in) throws IOException {
+      JsonElement parsed = JsonParser.parseReader(in);
+      if (parsed == null) {
+        return Optional.empty();
+      }
+      if (parsed.isJsonObject()) {
+        // If it's not a JSON object, then the boolean value is available directly in the Json
+        // element.
+        parsed = parsed.getAsJsonObject().get("value");
+      }
+      if (parsed == null || parsed.isJsonNull()) {
+        return Optional.empty();
+      }
+      return Optional.of(parsed.getAsBoolean());
+    }
+  }
+
+  /** Json serializer for the {@link ObjectId} class. */
+  static class ObjectIdAdapter extends TypeAdapter<ObjectId> {
+    private static final List<String> legacyFields = Arrays.asList("w1", "w2", "w3", "w4", "w5");
+
+    @Override
+    public void write(JsonWriter out, ObjectId value) throws IOException {
+      out.value(value.name());
+    }
+
+    @Override
+    public ObjectId read(JsonReader in) throws IOException {
+      JsonElement parsed = JsonParser.parseReader(in);
+      if (parsed.isJsonObject() && isJGitFormat(parsed)) {
+        // Some object IDs may have been serialized using the JGit format using the five integers
+        // w1, w2, w3, w4, w5. Detect this case so that we can deserialize properly.
+        int[] raw =
+            legacyFields.stream()
+                .mapToInt(field -> parsed.getAsJsonObject().get(field).getAsInt())
+                .toArray();
+        return ObjectId.fromRaw(raw);
+      }
+      return ObjectId.fromString(parsed.getAsString());
+    }
+
+    /** Return true if the json element contains the JGit serialized format of the Object ID. */
+    private boolean isJGitFormat(JsonElement elem) {
+      JsonObject asObj = elem.getAsJsonObject();
+      return legacyFields.stream().allMatch(field -> asObj.has(field));
+    }
+  }
+
   static class ImmutableListAdapter extends TypeAdapter<ImmutableList<String>> {
 
     @Override
@@ -69,4 +163,32 @@
       return builder.build();
     }
   }
+
+  /**
+   * A Json type adapter for the Status enum in {@link SubmitRequirementExpressionResult}. This
+   * adapter is able to parse unrecognized values. Unrecognized values are converted to the value
+   * "ERROR" The adapter is needed to ensure forward compatibility since we want to add more values
+   * to this enum. We do that to ensure safer rollout in distributed setups where some tasks are
+   * updated before others. We make sure that tasks running the old binaries are still able to parse
+   * values written by tasks running the new binaries.
+   *
+   * <p>TODO(ghareeb): Remove this adapter.
+   */
+  static class SubmitRequirementExpressionResultStatusAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult.Status> {
+    @Override
+    public void write(JsonWriter jsonWriter, Status status) throws IOException {
+      jsonWriter.value(status.name());
+    }
+
+    @Override
+    public Status read(JsonReader jsonReader) throws IOException {
+      String val = jsonReader.nextString();
+      try {
+        return SubmitRequirementExpressionResult.Status.valueOf(val);
+      } catch (IllegalArgumentException e) {
+        return SubmitRequirementExpressionResult.Status.ERROR;
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 28ab711..8f6ad67 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -22,39 +22,13 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import java.time.Instant;
-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 {
 
-  static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
-  static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
-  static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
-  static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
-  static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description");
-  static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
-  static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
-  static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
-  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  static final FooterKey FOOTER_TAG = new FooterKey("Tag");
-  static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
-  static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
-  static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
-
   static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
 
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
@@ -95,12 +69,13 @@
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
    */
-  public PersonIdent newAccountIdIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
+  public PersonIdent newAccountIdIdent(
+      Account.Id accountId, Instant when, PersonIdent serverIdent) {
     return new PersonIdent(
         getAccountIdAsUsername(accountId),
         getAccountIdAsEmailAddress(accountId),
         when,
-        serverIdent.getTimeZone());
+        serverIdent.getZoneId());
   }
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 2d9b014..52f540d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
@@ -31,7 +30,6 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
@@ -39,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.errorprone.annotations.FormatMethod;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
@@ -48,6 +47,7 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApprovals;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
@@ -66,15 +66,17 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -89,9 +91,6 @@
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
       Ordering.from(comparing(PatchSetApproval::granted));
 
-  public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
-      Ordering.from(comparing(ChangeMessage::getWrittenOn));
-
   @FormatMethod
   public static ConfigInvalidException parseException(
       Change.Id changeId, String fmt, Object... args) {
@@ -113,10 +112,52 @@
       this.projectCache = projectCache;
     }
 
+    @AutoValue
+    public abstract static class ScanResult {
+      abstract ImmutableSet<Change.Id> fromPatchSetRefs();
+
+      abstract ImmutableSet<Change.Id> fromMetaRefs();
+
+      public SetView<Change.Id> all() {
+        return Sets.union(fromPatchSetRefs(), fromMetaRefs());
+      }
+    }
+
+    public 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().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);
+        }
+      }
+      return new AutoValue_ChangeNotes_Factory_ScanResult(fromPs.build(), fromMeta.build());
+    }
+
     public ChangeNotes createChecked(Change c) {
       return createChecked(c.getProject(), c.getId());
     }
 
+    /**
+     * Load the change-notes associated to a project/change-id using an existing open repository
+     *
+     * @param repo existing open repository
+     * @param project project associated with the repository
+     * @param changeId change-id associated with the change-notes to load
+     * @param metaRevId version of the change-id to load, null for loading the latest
+     * @return change-notes object for the change
+     */
+    @UsedAt(UsedAt.Project.MODULE_GIT_REFS_FILTER)
+    public ChangeNotes createChecked(
+        Repository repo,
+        Project.NameKey project,
+        Change.Id changeId,
+        @Nullable ObjectId metaRevId) {
+      Change change = newChange(project, changeId);
+      return new ChangeNotes(args, change, true, null, metaRevId).load(repo);
+    }
+
     public ChangeNotes createChecked(
         Project.NameKey project, Change.Id changeId, @Nullable ObjectId metaRevId) {
       Change change = newChange(project, changeId);
@@ -137,6 +178,22 @@
       return new ChangeNotes(args, newChange(project, changeId), true, null).load();
     }
 
+    public ChangeNotes create(Repository repository, Project.NameKey project, Change.Id changeId) {
+      checkArgument(project != null, "project is required");
+      return new ChangeNotes(args, newChange(project, changeId), true, null).load(repository);
+    }
+
+    /**
+     * Create change notes for a change that was loaded from index. This method should only be used
+     * when database access is harmful and potentially stale data from the index is acceptable.
+     *
+     * @param change change loaded from secondary index
+     * @return change notes
+     */
+    public ChangeNotes createFromIndexedChange(Change change) {
+      return new ChangeNotes(args, change, true, null);
+    }
+
     public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist) {
       return new ChangeNotes(args, change, shouldExist, null).load();
     }
@@ -151,7 +208,7 @@
      * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
-      InternalChangeQuery query = queryProvider.get().noFields();
+      InternalChangeQuery query = queryProvider.get().setLimit(2).noFields();
       List<ChangeData> changes = query.byLegacyChangeId(changeId);
       if (changes.isEmpty()) {
         throw new NoSuchChangeException(changeId);
@@ -181,13 +238,14 @@
     }
 
     public List<ChangeNotes> create(
+        Repository repo,
         Project.NameKey project,
         Collection<Change.Id> changeIds,
         Predicate<ChangeNotes> predicate) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id cid : changeIds) {
         try {
-          ChangeNotes cn = create(project, cid);
+          ChangeNotes cn = create(repo, project, cid);
           if (cn.getChange() != null && predicate.test(cn)) {
             notes.add(cn);
           }
@@ -200,10 +258,32 @@
       return notes;
     }
 
+    /* TODO: This is now unused in the Gerrit code-base, however it is kept in the code
+    /* because it is a public method in a stable branch.
+     * It can be removed in master branch where we have more flexibility to change the API
+     * interface.
+     */
+    public List<ChangeNotes> create(
+        Project.NameKey project,
+        Collection<Change.Id> changeIds,
+        Predicate<ChangeNotes> predicate) {
+      try (Repository repo = args.repoManager.openRepository(project)) {
+        return create(repo, project, changeIds, predicate);
+      } catch (RepositoryNotFoundException e) {
+        // The repository does not exist, hence it does not contain
+        // any change.
+      } catch (IOException e) {
+        logger.atWarning().withCause(e).log(
+            "Unable to open project=%s when trying to retrieve changeId=%s from NoteDb",
+            project, changeIds);
+      }
+      return Collections.emptyList();
+    }
+
     public ListMultimap<Project.NameKey, ChangeNotes> create(Predicate<ChangeNotes> predicate)
         throws IOException {
-      ListMultimap<Project.NameKey, ChangeNotes> m =
-          MultimapBuilder.hashKeys().arrayListValues().build();
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeNotes> m =
+          ImmutableListMultimap.builder();
       for (Project.NameKey project : projectCache.all()) {
         try (Repository repo = args.repoManager.openRepository(project)) {
           scan(repo, project)
@@ -213,7 +293,7 @@
               .forEach(n -> m.put(n.getProjectName(), n));
         }
       }
-      return ImmutableListMultimap.copyOf(m);
+      return m.build();
     }
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
@@ -224,8 +304,11 @@
     public Stream<ChangeNotesResult> scan(
         Repository repo, Project.NameKey project, Predicate<Change.Id> changeIdPredicate)
         throws IOException {
-      ScanResult sr = scanChangeIds(repo);
+      return scan(scanChangeIds(repo), project, changeIdPredicate);
+    }
 
+    public Stream<ChangeNotesResult> scan(
+        ScanResult sr, Project.NameKey project, Predicate<Change.Id> changeIdPredicate) {
       Stream<Change.Id> idStream = sr.all().stream();
       if (changeIdPredicate != null) {
         idStream = idStream.filter(changeIdPredicate);
@@ -297,29 +380,6 @@
       @Nullable
       abstract ChangeNotes maybeNotes();
     }
-
-    @AutoValue
-    abstract static class ScanResult {
-      abstract ImmutableSet<Change.Id> fromPatchSetRefs();
-
-      abstract ImmutableSet<Change.Id> fromMetaRefs();
-
-      SetView<Change.Id> all() {
-        return Sets.union(fromPatchSetRefs(), fromMetaRefs());
-      }
-    }
-
-    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().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);
-        }
-      }
-      return new AutoValue_ChangeNotes_Factory_ScanResult(fromPs.build(), fromMeta.build());
-    }
   }
 
   private final boolean shouldExist;
@@ -338,8 +398,7 @@
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsWithCopied;
+  private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
 
   public ChangeNotes(
@@ -367,44 +426,27 @@
     return state.metaId();
   }
 
+  public String getServerId() {
+    return state.serverId();
+  }
+
   public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
     if (patchSets == null) {
-      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
-          ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
+      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b = ImmutableSortedMap.naturalOrder();
       b.putAll(state.patchSets());
       patchSets = b.build();
     }
     return patchSets;
   }
 
-  /**
-   * Gets the approvals, not including the copied approvals. To get copied approvals as well, use
-   * {@link #getApprovalsWithCopied}, or use {@code ApprovalInference}.
-   */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+  /** Gets the approvals of all patch sets. */
+  public PatchSetApprovals getApprovals() {
     if (approvals == null) {
-      approvals =
-          state.approvals().stream()
-              .filter(e -> !e.getValue().copied())
-              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
+      approvals = PatchSetApprovals.create(ImmutableListMultimap.copyOf(state.approvals()));
     }
     return approvals;
   }
 
-  /**
-   * This method is currently used only in tests. TODO(paiking): Use this method to fetch approvals
-   * (including copied approvals) instead of computing copied approvals on demand. This will be used
-   * by {@code ApprovalCache}.
-   *
-   * @return all approvals, including copied approvals.
-   */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
-    if (approvalsWithCopied == null) {
-      approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
-    }
-    return approvalsWithCopied;
-  }
-
   public ReviewerSet getReviewers() {
     return state.reviewers();
   }
@@ -440,11 +482,19 @@
 
   /**
    * Returns the evaluated submit requirements for the change. We only intend to store submit
-   * requirements in NoteDb for closed changes, hence the result will be an empty list for active
-   * changes, or a list of submit requirements results otherwise. For closed changes, the results
-   * represent the state of evaluating submit requirements for this change when it was merged.
+   * requirements in NoteDb for closed changes. For closed changes, the results represent the state
+   * of evaluating submit requirements for this change when it was merged or abandoned.
+   *
+   * @throws UnsupportedOperationException if submit requirements are requested for an open change.
    */
   public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    if (state.columns().status().isOpen()) {
+      throw new UnsupportedOperationException(
+          String.format(
+              "Cannot request stored submit requirements"
+                  + " for an open change: project = %s, change ID = %d",
+              getProjectName(), state.changeId().get()));
+    }
     return state.submitRequirementsResult();
   }
 
@@ -512,7 +562,7 @@
   }
 
   /** Returns {@link Optional} value of time when the change was merged. */
-  public Optional<Timestamp> getMergedOn() {
+  public Optional<Instant> getMergedOn() {
     return Optional.ofNullable(state.mergedOn());
   }
 
@@ -628,7 +678,9 @@
      * be to bump the cache version, but that would invalidate all persistent cache entries, what we
      * rather try to avoid.
      */
-    if (!Strings.isNullOrEmpty(stateServerId) && !args.serverId.equals(stateServerId)) {
+    if (!Strings.isNullOrEmpty(stateServerId)
+        && !args.serverId.equals(stateServerId)
+        && !args.importedServerIds.contains(stateServerId)) {
       throw new InvalidServerIdException(args.serverId, stateServerId);
     }
 
@@ -646,6 +698,7 @@
     return change.getProject();
   }
 
+  @Nullable
   @Override
   protected ObjectId readRef(Repository repo) throws IOException {
     return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index c554ca5..0f2c877 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -61,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(2)
+            .version(5)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
@@ -366,7 +367,13 @@
           "Load change notes for change %s of project %s", key.changeId(), key.project());
       ChangeNotesParser parser =
           new ChangeNotesParser(
-              key.changeId(), key.id(), walkSupplier.get(), args.changeNoteJson, args.metrics);
+              key.changeId(),
+              key.id(),
+              walkSupplier.get(),
+              args.changeNoteJson,
+              args.metrics,
+              args.serverId,
+              externalIdCache);
       ChangeNotesState result = parser.parseAll();
       // This assignment only happens if call() was actually called, which only
       // happens when Cache#get(K, Callable<V>) incurs a cache miss.
@@ -377,11 +384,16 @@
 
   private final Cache<Key, ChangeNotesState> cache;
   private final Args args;
+  private final ExternalIdCache externalIdCache;
 
   @Inject
-  ChangeNotesCache(@Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache, Args args) {
+  ChangeNotesCache(
+      @Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache,
+      Args args,
+      ExternalIdCache externalIdCache) {
     this.cache = cache;
     this.args = args;
+    this.externalIdCache = externalIdCache;
   }
 
   Value get(
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 76573f6..38ab8e9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
new file mode 100644
index 0000000..83ee6ec
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
@@ -0,0 +1,242 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/**
+ * Util to extract {@link com.google.gerrit.entities.PatchSetApproval} from {@link
+ * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+ */
+public class ChangeNotesParseApprovalUtil {
+
+  /**
+   * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link
+   * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+   *
+   * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent
+   * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed
+   * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link
+   * #footerLine} values.
+   */
+  @AutoValue
+  public abstract static class ParsedPatchSetApproval {
+
+    /** The original footer value, that this entity was parsed from. */
+    public abstract String footerLine();
+
+    public abstract boolean isRemoval();
+
+    /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */
+    public abstract String labelVote();
+
+    public abstract Optional<String> uuid();
+
+    public abstract Optional<String> accountIdent();
+
+    public abstract Optional<String> realAccountIdent();
+
+    public abstract Optional<String> tag();
+
+    public static Builder builder() {
+      return new AutoValue_ChangeNotesParseApprovalUtil_ParsedPatchSetApproval.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      abstract Builder footerLine(String labelLine);
+
+      abstract Builder isRemoval(boolean isRemoval);
+
+      abstract Builder labelVote(String labelVote);
+
+      abstract Builder uuid(Optional<String> uuid);
+
+      abstract Builder accountIdent(Optional<String> accountIdent);
+
+      abstract Builder realAccountIdent(Optional<String> realAccountIdent);
+
+      abstract Builder tag(Optional<String> tag);
+
+      abstract ParsedPatchSetApproval build();
+    }
+  }
+
+  /**
+   * Parses {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL} line.
+   *
+   * <p>Valid added approval footer examples:
+   *
+   * <ul>
+   *   <li>Label: <LABEL>=VOTE
+   *   <li>Label: <LABEL>=VOTE <Gerrit Account>
+   *   <li>Label: <LABEL>=VOTE, <UUID>
+   *   <li>Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
+   * </ul>
+   *
+   * <p>Valid removed approval footer examples:
+   *
+   * <ul>
+   *   <li>-<LABEL>
+   *   <li>-<LABEL> <Gerrit Account>
+   * </ul>
+   *
+   * <p><UUID> is optional, since the approval might have been granted before {@link
+   * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *
+   * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does
+   * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+   */
+  public static ParsedPatchSetApproval parseApproval(String footerLine)
+      throws ConfigInvalidException {
+    try {
+      ParsedPatchSetApproval.Builder rawPatchSetApproval =
+          ParsedPatchSetApproval.builder().footerLine(footerLine);
+      String labelVoteStr;
+      boolean isRemoval = footerLine.startsWith("-");
+      rawPatchSetApproval.isRemoval(isRemoval);
+      int uuidStart = isRemoval ? -1 : footerLine.indexOf(", ");
+      int reviewerStart = footerLine.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
+      int labelStart = isRemoval ? 1 : 0;
+      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, footerLine);
+
+      if (uuidStart != -1) {
+        String uuid =
+            footerLine.substring(
+                uuidStart + 2, reviewerStart > 0 ? reviewerStart : footerLine.length());
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine);
+        labelVoteStr = footerLine.substring(labelStart, uuidStart);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+      } else if (reviewerStart != -1) {
+        labelVoteStr = footerLine.substring(labelStart, reviewerStart);
+      } else {
+        labelVoteStr = footerLine.substring(labelStart);
+      }
+      rawPatchSetApproval.labelVote(labelVoteStr);
+
+      if (reviewerStart > 0) {
+        String ident = footerLine.substring(reviewerStart + 1);
+        rawPatchSetApproval.accountIdent(Optional.of(ident));
+      }
+      return rawPatchSetApproval.build();
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_LABEL, footerLine, ex);
+    }
+  }
+
+  /**
+   * Parses copied {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}
+   * line.
+   *
+   * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account>
+   * :"<TAG>"
+   *
+   * <ul>
+   *   <li>":<"TAG>"" is optional.
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   *   <li><UUID> is optional, since the approval might have been granted before {@link
+   *       com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *   <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
+   *       Account is also optional since by default it's the committer).
+   * </ul>
+   *
+   * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
+   *
+   * <ul>
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   * </ul>
+   */
+  public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
+      throws ConfigInvalidException {
+    try {
+      ParsedPatchSetApproval.Builder rawPatchSetApproval =
+          ParsedPatchSetApproval.builder().footerLine(labelLine);
+
+      boolean isRemoval = labelLine.startsWith("-");
+      rawPatchSetApproval.isRemoval(isRemoval);
+      int labelStart = isRemoval ? 1 : 0;
+      int uuidStart = isRemoval ? -1 : labelLine.indexOf(", ");
+      int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
+
+      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
+
+      // Weird tag that contains uuid delimiter. The uuid is actually not present.
+      if (tagStart != -1 && uuidStart > tagStart) {
+        uuidStart = -1;
+      }
+
+      int identitiesStart = labelLine.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
+      checkFooter(
+          identitiesStart != -1 && identitiesStart < labelLine.length(),
+          FOOTER_COPIED_LABEL,
+          labelLine);
+
+      String labelVoteStr =
+          labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
+      rawPatchSetApproval.labelVote(labelVoteStr);
+      if (uuidStart != -1) {
+        String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+      }
+      // The first account is the accountId, and second (if applicable) is the realAccountId.
+      List<String> identities =
+          Splitter.on(',')
+              .splitToList(
+                  labelLine.substring(
+                      identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart));
+      checkFooter(identities.size() >= 1, FOOTER_COPIED_LABEL, labelLine);
+
+      rawPatchSetApproval.accountIdent(Optional.of(identities.get(0)));
+
+      if (identities.size() > 1) {
+        rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1)));
+      }
+
+      if (tagStart != -1) {
+        // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
+        // line.length()-1 skips the last ".
+        String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1);
+        rawPatchSetApproval.tag(Optional.of(tag));
+      }
+      return rawPatchSetApproval.build();
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_COPIED_LABEL, labelLine, ex);
+    }
+  }
+
+  private static void checkFooter(boolean expr, FooterKey footer, String actual)
+      throws ConfigInvalidException {
+    if (!expr) {
+      throw parseException(footer, actual, /*cause=*/ null);
+    }
+  }
+
+  private static ConfigInvalidException parseException(
+      FooterKey footer, String actual, Throwable cause) {
+    return new ConfigInvalidException(
+        String.format("invalid %s: %s", footer.getName(), actual), cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 5cf3a64..77d1c8f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -15,36 +15,38 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-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_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-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.ChangeNoteFooters.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static java.util.Comparator.comparing;
+import static java.util.Comparator.comparingInt;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
@@ -70,19 +72,23 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Label.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.notedb.ChangeNotesParseApprovalUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -95,6 +101,7 @@
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -104,9 +111,37 @@
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.util.RawParseUtils;
 
+/**
+ * Parses {@link ChangeNotesState} out of the change meta ref.
+ *
+ * <p>NOTE: all changes to the change notes storage format must be both forward and backward
+ * compatible, i.e.:
+ *
+ * <ul>
+ *   <li>The server, running the new binary version must be able to parse the data, written by the
+ *       previous binary version.
+ *   <li>The server, running the old binary version must be able to parse the data, written by the
+ *       new binary version.
+ * </ul>
+ *
+ * <p>Thus, when introducing storage format update, the following procedure must be used:
+ *
+ * <ol>
+ *   <li>The read path ({@link ChangeNotesParser}) needs to be updated to handle both the old and
+ *       the new data format.
+ *   <li>In a separate change, the write path (e.g. {@link ChangeUpdate}, {@link ChangeNoteJson}) is
+ *       updated to write the new format, guarded by {@link
+ *       com.google.gerrit.server.experiments.ExperimentFeatures} flag, if possible.
+ *   <li>Once the 'read' change is roll out and is roll back safe, the 'write' change can be
+ *       submitted/the experiment flag can be flipped.
+ * </ol>
+ */
 class ChangeNotesParser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Splitter RULE_SPLITTER = Splitter.on(": ");
+  private static final Splitter HASHTAG_SPLITTER = Splitter.on(",");
+
   // Private final members initialized in the constructor.
   private final ChangeNoteJson changeNoteJson;
   private final NoteDbMetrics metrics;
@@ -116,8 +151,8 @@
 
   // Private final but mutable members initialized in the constructor and filled
   // in during the parsing process.
-  private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
-  private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
+  private final Table<Account.Id, ReviewerStateInternal, Instant> reviewers;
+  private final Table<Address, ReviewerStateInternal, Instant> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
@@ -142,8 +177,8 @@
   private Change.Status status;
   private String topic;
   private Set<String> hashtags;
-  private Timestamp createdOn;
-  private Timestamp lastUpdatedOn;
+  private Instant createdOn;
+  private Instant lastUpdatedOn;
   private Account.Id ownerId;
   private String serverId;
   private String changeId;
@@ -164,19 +199,25 @@
   // We only set the value once, based on the latest update (the actual value or Optional.empty() if
   // the latest record unsets the field).
   private Optional<PatchSet.Id> cherryPickOf;
-  private Timestamp mergedOn;
+  private Instant mergedOn;
+  private final ExternalIdCache externalIdCache;
+  private final String gerritServerId;
 
   ChangeNotesParser(
       Change.Id changeId,
       ObjectId tip,
       ChangeNotesRevWalk walk,
       ChangeNoteJson changeNoteJson,
-      NoteDbMetrics metrics) {
+      NoteDbMetrics metrics,
+      String gerritServerId,
+      ExternalIdCache externalIdCache) {
     this.id = changeId;
     this.tip = tip;
     this.walk = walk;
     this.changeNoteJson = changeNoteJson;
     this.metrics = metrics;
+    this.externalIdCache = externalIdCache;
+    this.gerritServerId = gerritServerId;
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
@@ -289,6 +330,7 @@
     return result;
   }
 
+  @Nullable
   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.
@@ -312,10 +354,81 @@
       }
       result.put(a.key().patchSetId(), a.build());
     }
+    if (status != null && status.isClosed() && !isAnyApprovalCopied(result)) {
+      // If the change is closed, check if there are "submit records" with approvals that do not
+      // exist on the latest patch-set and copy them to the latest patch-set.
+      // We do not invoke this logic if any approval is copied. This is because prior to change
+      // https://gerrit-review.googlesource.com/c/gerrit/+/318135 we used to copy approvals
+      // dynamically (e.g. when requesting the change page). After that change, we started
+      // persisting copied votes in NoteDb, so we don't need to do this back-filling.
+      // Prior to that change (318135), we could've had changes with dynamically copied approvals
+      // that were merged in NoteDb but these approvals do not exist on the latest patch-set, so
+      // we need to back-fill these approvals.
+      PatchSet.Id latestPs = buildCurrentPatchSetId();
+      backFillMissingCopiedApprovalsFromSubmitRecords(result, latestPs).stream()
+          .forEach(a -> result.put(latestPs, a));
+    }
     result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
   }
 
+  /**
+   * Returns patch-set approvals that do not exist on the latest patch-set but for which a submit
+   * record exists in NoteDb when the change was merged.
+   */
+  private List<PatchSetApproval> backFillMissingCopiedApprovalsFromSubmitRecords(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals, @Nullable PatchSet.Id latestPs) {
+    List<PatchSetApproval> copiedApprovals = new ArrayList<>();
+    if (latestPs == null) {
+      return copiedApprovals;
+    }
+    List<PatchSetApproval> approvalsOnLatestPs = allApprovals.get(latestPs);
+    ListMultimap<Account.Id, PatchSetApproval> approvalsByUser = getApprovalsByUser(allApprovals);
+    List<SubmitRecord.Label> submitRecordLabels =
+        submitRecords.stream()
+            .filter(r -> r.labels != null)
+            .flatMap(r -> r.labels.stream())
+            .filter(label -> Status.OK.equals(label.status) || Status.MAY.equals(label.status))
+            .collect(Collectors.toList());
+    for (SubmitRecord.Label recordLabel : submitRecordLabels) {
+      String labelName = recordLabel.label;
+      Account.Id appliedBy = recordLabel.appliedBy;
+      if (appliedBy == null || labelName == null) {
+        continue;
+      }
+      boolean existsAtLatestPs =
+          approvalsOnLatestPs.stream()
+              .anyMatch(a -> a.accountId().equals(appliedBy) && a.label().equals(labelName));
+      if (existsAtLatestPs) {
+        continue;
+      }
+      // Search for an approval for this label on the max previous patch-set and copy the approval.
+      Collection<PatchSetApproval> userApprovals =
+          approvalsByUser.get(appliedBy).stream()
+              .filter(approval -> approval.label().equals(labelName))
+              .collect(Collectors.toList());
+      if (userApprovals.isEmpty()) {
+        continue;
+      }
+      PatchSetApproval lastApproved =
+          Collections.max(userApprovals, comparingInt(a -> a.patchSetId().get()));
+      copiedApprovals.add(lastApproved.copyWithPatchSet(latestPs));
+    }
+    return copiedApprovals;
+  }
+
+  private boolean isAnyApprovalCopied(ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream().anyMatch(approval -> approval.copied());
+  }
+
+  private ListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream()
+        .collect(
+            ImmutableListMultimap.toImmutableListMultimap(
+                PatchSetApproval::accountId, Function.identity()));
+  }
+
   private List<ReviewerStatusUpdate> buildReviewerUpdates() {
     List<ReviewerStatusUpdate> result = new ArrayList<>();
     HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
@@ -333,7 +446,7 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    Timestamp commitTimestamp = getCommitTimestamp(commit);
+    Instant commitTimestamp = getCommitTimestamp(commit);
 
     createdOn = commitTimestamp;
     parseTag(commit);
@@ -387,7 +500,7 @@
 
     parseSubmission(commit, commitTimestamp);
 
-    if (lastUpdatedOn == null || commitTimestamp.after(lastUpdatedOn)) {
+    if (lastUpdatedOn == null || commitTimestamp.isAfter(lastUpdatedOn)) {
       lastUpdatedOn = commitTimestamp;
     }
 
@@ -449,7 +562,7 @@
     }
   }
 
-  private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
+  private void parseSubmission(ChangeNotesCommit commit, Instant commitTimestamp)
       throws ConfigInvalidException {
     // Only parse the most recent sumbit commit (there should be exactly one).
     if (submissionId == null) {
@@ -471,6 +584,7 @@
     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
   }
 
+  @Nullable
   private String parseBranch(ChangeNotesCommit commit) throws ConfigInvalidException {
     String branch = parseOneFooter(commit, FOOTER_BRANCH);
     return branch != null ? RefNames.fullName(branch) : null;
@@ -498,6 +612,7 @@
     return parseOneFooter(commit, FOOTER_TOPIC);
   }
 
+  @Nullable
   private String parseOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
       throws ConfigInvalidException {
     List<String> footerLines = commit.getFooterLineValues(footerKey);
@@ -518,6 +633,7 @@
     return line;
   }
 
+  @Nullable
   private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException {
     String sha = parseOneFooter(commit, FOOTER_COMMIT);
     if (sha == null) {
@@ -532,7 +648,7 @@
     }
   }
 
-  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Timestamp ts)
+  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Instant ts)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
@@ -605,7 +721,7 @@
     } else if (hashtagsLines.get(0).isEmpty()) {
       hashtags = ImmutableSet.of();
     } else {
-      hashtags = Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
+      hashtags = Sets.newHashSet(HASHTAG_SPLITTER.split(hashtagsLines.get(0)));
     }
   }
 
@@ -627,7 +743,7 @@
     }
   }
 
-  private void parseAssigneeUpdates(Timestamp ts, ChangeNotesCommit commit)
+  private void parseAssigneeUpdates(Instant ts, ChangeNotesCommit commit)
       throws ConfigInvalidException {
     String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
     if (assigneeValue != null) {
@@ -655,6 +771,7 @@
     }
   }
 
+  @Nullable
   private Change.Status parseStatus(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
     if (statusLines.isEmpty()) {
@@ -693,6 +810,7 @@
     return PatchSet.id(id, psId);
   }
 
+  @Nullable
   private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     int s = psIdLine.indexOf(' ');
@@ -737,7 +855,7 @@
       Account.Id accountId,
       Account.Id realAccountId,
       ChangeNotesCommit commit,
-      Timestamp ts) {
+      Instant ts) {
     Optional<String> changeMsgString = getChangeMessageString(commit);
     if (!changeMsgString.isPresent()) {
       return false;
@@ -783,8 +901,28 @@
       for (HumanComment c : e.getValue().getEntities()) {
         humanComments.put(e.getKey(), c);
       }
-      for (SubmitRequirementResult sr : e.getValue().getSubmitRequirementsResult()) {
-        submitRequirementResults.add(sr);
+    }
+
+    // Lookup submit requirement results from the revision notes of the last PS that has stored
+    // submit requirements. This is important for cases where the change was abandoned/un-abandoned
+    // multiple times. With each abandon, we store submit requirement results in NoteDb, so we can
+    // end up having stored SRs in many revision notes. We should only return SRs from the last
+    // PS of them.
+    for (PatchSet.Builder ps :
+        patchSets.values().stream()
+            .sorted(comparingInt((PatchSet.Builder p) -> p.id().get()).reversed())
+            .collect(Collectors.toList())) {
+      Optional<ObjectId> maybePsCommitId = ps.commitId();
+      if (!maybePsCommitId.isPresent()) {
+        continue;
+      }
+      ObjectId psCommitId = maybePsCommitId.get();
+      if (rns.containsKey(psCommitId)
+          && rns.get(psCommitId).getSubmitRequirementsResult() != null) {
+        rns.get(psCommitId)
+            .getSubmitRequirementsResult()
+            .forEach(sr -> submitRequirementResults.add(sr));
+        break;
       }
     }
 
@@ -801,61 +939,53 @@
     }
   }
 
-  // Footer example: Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
-  // ":<"TAG>"" is optional. <Gerrit Real Account> is also optional, if it was not set.
-  // The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
-  // Account is also optional since by default it's the committer).
-  private void parseCopiedApproval(PatchSet.Id psId, Timestamp ts, String line)
+  /** Parses copied {@link PatchSetApproval}. */
+  private void parseCopiedApproval(PatchSet.Id psId, Instant ts, String line)
       throws ConfigInvalidException {
-    // Copied approvals can't be explicitly removed. They are removed the same way as non-copied
-    // approvals.
-    checkFooter(!line.startsWith("-"), FOOTER_COPIED_LABEL, line);
+    ParsedPatchSetApproval parsedPatchSetApproval =
+        ChangeNotesParseApprovalUtil.parseCopiedApproval(line);
+    checkFooter(
+        parsedPatchSetApproval.accountIdent().isPresent(),
+        FOOTER_COPIED_LABEL,
+        parsedPatchSetApproval.footerLine());
+    PersonIdent accountIdent =
+        RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
 
-    Account.Id accountId, realAccountId = null;
-    String labelVoteStr;
-    String tag = null;
-    int s = line.indexOf(' ');
-    int tagStart = line.indexOf(":\"");
+    checkFooter(accountIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
+    Account.Id accountId = parseIdent(accountIdent);
 
-    // The first account is the accountId, and second (if applicable) is the realAccountId.
-    try {
-      labelVoteStr = line.substring(0, s);
-    } catch (StringIndexOutOfBoundsException ex) {
-      throw new ConfigInvalidException(ex.getMessage(), ex);
-    }
-    String[] identities =
-        line.substring(s + 1, tagStart == -1 ? line.length() : tagStart).split(",");
-    PersonIdent ident = RawParseUtils.parsePersonIdent(identities[0]);
-    checkFooter(ident != null, FOOTER_COPIED_LABEL, line);
-    accountId = parseIdent(ident);
-
-    if (identities.length > 1) {
-      PersonIdent realIdent = RawParseUtils.parsePersonIdent(identities[1]);
-      checkFooter(realIdent != null, FOOTER_COPIED_LABEL, line);
+    Account.Id realAccountId = null;
+    if (parsedPatchSetApproval.realAccountIdent().isPresent()) {
+      PersonIdent realIdent =
+          RawParseUtils.parsePersonIdent(parsedPatchSetApproval.realAccountIdent().get());
+      checkFooter(realIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
       realAccountId = parseIdent(realIdent);
     }
 
-    LabelVote l;
+    LabelVote labelVote;
     try {
-      l = LabelVote.parseWithEquals(labelVoteStr);
+      if (!parsedPatchSetApproval.isRemoval()) {
+        labelVote = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
+      } else {
+        String labelName = parsedPatchSetApproval.labelVote();
+        LabelType.checkNameInternal(labelName);
+        labelVote = LabelVote.create(labelName, (short) 0);
+      }
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_COPIED_LABEL, line);
+      ConfigInvalidException pe =
+          parseException(
+              "invalid %s: %s", FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
 
-    if (tagStart != -1) {
-      // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
-      // line.length()-1 skips the last ".
-      tag = line.substring(tagStart + 2, line.length() - 1);
-    }
-
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, accountId, LabelId.create(l.label())))
-            .value(l.value())
+            .key(PatchSetApproval.key(psId, accountId, LabelId.create(labelVote.label())))
+            .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
+            .value(labelVote.value())
             .granted(ts)
-            .tag(Optional.ofNullable(tag))
+            .tag(parsedPatchSetApproval.tag())
             .copied(true);
     if (realAccountId != null) {
       psa.realAccountId(realAccountId);
@@ -865,60 +995,47 @@
   }
 
   private void parseApproval(
-      PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Instant ts, String line)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
     PatchSetApproval.Builder psa;
+    ParsedPatchSetApproval parsedPatchSetApproval =
+        ChangeNotesParseApprovalUtil.parseApproval(line);
     if (line.startsWith("-")) {
-      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
+      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
     } else {
-      psa = parseAddApproval(psId, accountId, realAccountId, ts, line);
+      psa = parseAddApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
     }
     bufferedApprovals.add(psa);
   }
 
+  /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteFooters#FOOTER_LABEL} value. */
   private PatchSetApproval.Builder parseAddApproval(
-      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId,
+      Account.Id committerId,
+      Account.Id realAccountId,
+      Instant ts,
+      ParsedPatchSetApproval parsedPatchSetApproval)
       throws ConfigInvalidException {
-    // There are potentially 3 accounts involved here:
-    //  1. The account from the commit, which is the effective IdentifiedUser
-    //     that produced the update.
-    //  2. The account in the label footer itself, which is used during submit
-    //     to copy other users' labels to a new patch set.
-    //  3. The account in the Real-user footer, indicating that the whole
-    //     update operation was executed by this user on behalf of the effective
-    //     user.
-    Account.Id effectiveAccountId;
-    String labelVoteStr;
-    int s = line.indexOf(' ');
-    if (s > 0) {
-      // Account in the label line (2) becomes the effective ID of the
-      // approval. If there is a real user (3) different from the commit user
-      // (2), we actually don't store that anywhere in this case; it's more
-      // important to record that the real user (3) actually initiated submit.
-      labelVoteStr = line.substring(0, s);
-      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
-      checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = parseIdent(ident);
-    } else {
-      labelVoteStr = line;
-      effectiveAccountId = committerId;
-    }
+
+    Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
 
     LabelVote l;
     try {
-      l = LabelVote.parseWithEquals(labelVoteStr);
+      l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
 
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(l.label())))
+            .key(PatchSetApproval.key(psId, approverId, LabelId.create(l.label())))
+            .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
             .value(l.value())
             .granted(ts)
             .tag(Optional.ofNullable(tag));
@@ -930,26 +1047,24 @@
   }
 
   private PatchSetApproval.Builder parseRemoveApproval(
-      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId,
+      Account.Id committerId,
+      Account.Id realAccountId,
+      Instant ts,
+      ParsedPatchSetApproval parsedPatchSetApproval)
       throws ConfigInvalidException {
-    // See comments in parseAddApproval about the various users involved.
-    Account.Id effectiveAccountId;
-    String label;
-    int s = line.indexOf(' ');
-    if (s > 0) {
-      label = line.substring(1, s);
-      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
-      checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = parseIdent(ident);
-    } else {
-      label = line.substring(1);
-      effectiveAccountId = committerId;
-    }
+
+    checkFooter(
+        parsedPatchSetApproval.footerLine().startsWith("-"),
+        FOOTER_LABEL,
+        parsedPatchSetApproval.footerLine());
+    Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
 
     try {
-      LabelType.checkNameInternal(label);
+      LabelType.checkNameInternal(parsedPatchSetApproval.labelVote());
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
@@ -958,7 +1073,9 @@
     // 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)))
+            .key(
+                PatchSetApproval.key(
+                    psId, approverId, LabelId.create(parsedPatchSetApproval.labelVote())))
             .value(0)
             .granted(ts);
     if (!Objects.equals(realAccountId, committerId)) {
@@ -968,6 +1085,30 @@
     return remove;
   }
 
+  /**
+   * Identifies the {@link com.google.gerrit.entities.Account.Id} that issued the vote.
+   *
+   * <p>There are potentially 3 accounts involved here: 1. The account from the commit, which is the
+   * effective IdentifiedUser that produced the update. 2. The account in the label footer itself,
+   * which is used during submit to copy other users' labels to a new patch set. 3. The account in
+   * the Real-user footer, indicating that the whole update operation was executed by this user on
+   * behalf of the effective user.
+   */
+  private Account.Id parseApprover(
+      Account.Id committerId, ParsedPatchSetApproval parsedPatchSetApproval)
+      throws ConfigInvalidException {
+    Account.Id effectiveAccountId;
+    if (parsedPatchSetApproval.accountIdent().isPresent()) {
+      PersonIdent ident =
+          RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
+      checkFooter(ident != null, FOOTER_LABEL, parsedPatchSetApproval.footerLine());
+      effectiveAccountId = parseIdent(ident);
+    } else {
+      effectiveAccountId = committerId;
+    }
+    return effectiveAccountId;
+  }
+
   private void parseSubmitRecords(List<String> lines) throws ConfigInvalidException {
     SubmitRecord rec = null;
 
@@ -986,7 +1127,7 @@
       } else {
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
         if (line.startsWith("Rule-Name: ")) {
-          String ruleName = line.split(": ")[1];
+          String ruleName = RULE_SPLITTER.splitToList(line).get(1);
           rec.ruleName = ruleName;
           continue;
         }
@@ -1012,6 +1153,7 @@
     }
   }
 
+  @Nullable
   private Account.Id parseIdent(ChangeNotesCommit commit) throws ConfigInvalidException {
     // Check if the author name/email is the same as the committer name/email,
     // i.e. was the server ident at the time this commit was made.
@@ -1023,7 +1165,7 @@
     return parseIdent(a);
   }
 
-  private void parseReviewer(Timestamp ts, ReviewerStateInternal state, String line)
+  private void parseReviewer(Instant ts, ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     PersonIdent ident = RawParseUtils.parsePersonIdent(line);
     if (ident == null) {
@@ -1036,7 +1178,7 @@
     }
   }
 
-  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+  private void parseReviewerByEmail(Instant ts, ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     Address adr;
     try {
@@ -1097,6 +1239,7 @@
     throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
   }
 
+  @Nullable
   private Change.Id parseRevertOf(ChangeNotesCommit commit) throws ConfigInvalidException {
     String footer = parseOneFooter(commit, FOOTER_REVERT_OF);
     if (footer == null) {
@@ -1110,7 +1253,7 @@
   }
 
   /**
-   * Parses {@link ChangeNoteUtil#FOOTER_CHERRY_PICK_OF} of the commit.
+   * Parses {@link ChangeNoteFooters#FOOTER_CHERRY_PICK_OF} of the commit.
    *
    * @param commit the commit to parse.
    * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
@@ -1150,15 +1293,15 @@
    * @param commit the commit to return commit time.
    * @return the timestamp when the commit was applied.
    */
-  private Timestamp getCommitTimestamp(ChangeNotesCommit commit) {
-    return new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+  private Instant getCommitTimestamp(ChangeNotesCommit commit) {
+    return commit.getCommitterIdent().getWhenAsInstant();
   }
 
   private void pruneReviewers() {
-    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
+    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Instant>> rit =
         reviewers.cellSet().iterator();
     while (rit.hasNext()) {
-      Table.Cell<Account.Id, ReviewerStateInternal, Timestamp> e = rit.next();
+      Table.Cell<Account.Id, ReviewerStateInternal, Instant> e = rit.next();
       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
       }
@@ -1166,10 +1309,10 @@
   }
 
   private void pruneReviewersByEmail() {
-    Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
+    Iterator<Table.Cell<Address, ReviewerStateInternal, Instant>> rit =
         reviewersByEmail.cellSet().iterator();
     while (rit.hasNext()) {
-      Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
+      Table.Cell<Address, ReviewerStateInternal, Instant> e = rit.next();
       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
       }
@@ -1280,7 +1423,7 @@
   }
 
   private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
-    return NoteDbUtil.parseIdent(ident)
+    return NoteDbUtil.parseIdent(ident, gerritServerId, externalIdCache)
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 4d6b9cf..b0079d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -65,7 +65,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -103,8 +102,8 @@
       ObjectId metaId,
       Change.Id changeId,
       Change.Key changeKey,
-      Timestamp createdOn,
-      Timestamp lastUpdatedOn,
+      Instant createdOn,
+      Instant lastUpdatedOn,
       Account.Id owner,
       String serverId,
       String branch,
@@ -136,7 +135,7 @@
       @Nullable Change.Id revertOf,
       @Nullable PatchSet.Id cherryPickOf,
       int updateCount,
-      @Nullable Timestamp mergedOn) {
+      @Nullable Instant mergedOn) {
     requireNonNull(
         metaId,
         () ->
@@ -203,9 +202,9 @@
 
     abstract Change.Key changeKey();
 
-    abstract Timestamp createdOn();
+    abstract Instant createdOn();
 
-    abstract Timestamp lastUpdatedOn();
+    abstract Instant lastUpdatedOn();
 
     abstract Account.Id owner();
 
@@ -249,9 +248,9 @@
 
       abstract Builder changeKey(Change.Key changeKey);
 
-      abstract Builder createdOn(Timestamp createdOn);
+      abstract Builder createdOn(Instant createdOn);
 
-      abstract Builder lastUpdatedOn(Timestamp lastUpdatedOn);
+      abstract Builder lastUpdatedOn(Instant lastUpdatedOn);
 
       abstract Builder owner(Account.Id owner);
 
@@ -334,7 +333,7 @@
   abstract int updateCount();
 
   @Nullable
-  abstract Timestamp mergedOn();
+  abstract Instant mergedOn();
 
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
@@ -456,7 +455,7 @@
 
     abstract Builder updateCount(int updateCount);
 
-    abstract Builder mergedOn(Timestamp mergedOn);
+    abstract Builder mergedOn(Instant mergedOn);
 
     abstract ChangeNotesState build();
   }
@@ -536,7 +535,7 @@
                       SubmitRequirementProtoConverter.INSTANCE.toProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
-        b.setMergedOnMillis(object.mergedOn().getTime());
+        b.setMergedOnMillis(object.mergedOn().toEpochMilli());
         b.setHasMergedOn(true);
       }
 
@@ -547,8 +546,8 @@
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
               .setChangeKey(cols.changeKey().get())
-              .setCreatedOnMillis(cols.createdOn().getTime())
-              .setLastUpdatedOnMillis(cols.lastUpdatedOn().getTime())
+              .setCreatedOnMillis(cols.createdOn().toEpochMilli())
+              .setLastUpdatedOnMillis(cols.lastUpdatedOn().toEpochMilli())
               .setOwner(cols.owner().get())
               .setBranch(cols.branch());
       if (cols.currentPatchSetId() != null) {
@@ -581,26 +580,26 @@
     }
 
     private static ReviewerSetEntryProto toReviewerSetEntry(
-        Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c) {
+        Table.Cell<ReviewerStateInternal, Account.Id, Instant> c) {
       return ReviewerSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAccountId(c.getColumnKey().get())
-          .setTimestampMillis(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().toEpochMilli())
           .build();
     }
 
     private static ReviewerByEmailSetEntryProto toReviewerByEmailSetEntry(
-        Table.Cell<ReviewerStateInternal, Address, Timestamp> c) {
+        Table.Cell<ReviewerStateInternal, Address, Instant> c) {
       return ReviewerByEmailSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAddress(c.getColumnKey().toHeaderString())
-          .setTimestampMillis(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().toEpochMilli())
           .build();
     }
 
     private static ReviewerStatusUpdateProto toReviewerStatusUpdateProto(ReviewerStatusUpdate u) {
       return ReviewerStatusUpdateProto.newBuilder()
-          .setTimestampMillis(u.date().getTime())
+          .setTimestampMillis(u.date().toEpochMilli())
           .setUpdatedBy(u.updatedBy().get())
           .setReviewer(u.reviewer().get())
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(u.state()))
@@ -620,7 +619,7 @@
     private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
       AssigneeStatusUpdateProto.Builder builder =
           AssigneeStatusUpdateProto.newBuilder()
-              .setTimestampMillis(u.date().getTime())
+              .setTimestampMillis(u.date().toEpochMilli())
               .setUpdatedBy(u.updatedBy().get())
               .setHasCurrentAssignee(u.currentAssignee().isPresent());
 
@@ -678,7 +677,8 @@
                       .map(sr -> SubmitRequirementProtoConverter.INSTANCE.fromProto(sr))
                       .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
-              .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
+              .mergedOn(
+                  proto.getHasMergedOn() ? Instant.ofEpochMilli(proto.getMergedOnMillis()) : null);
       return b.build();
     }
 
@@ -686,8 +686,8 @@
       ChangeColumns.Builder b =
           ChangeColumns.builder()
               .changeKey(Change.key(proto.getChangeKey()))
-              .createdOn(new Timestamp(proto.getCreatedOnMillis()))
-              .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOnMillis()))
+              .createdOn(Instant.ofEpochMilli(proto.getCreatedOnMillis()))
+              .lastUpdatedOn(Instant.ofEpochMilli(proto.getLastUpdatedOnMillis()))
               .owner(Account.id(proto.getOwner()))
               .branch(proto.getBranch());
       if (proto.getHasCurrentPatchSetId()) {
@@ -719,26 +719,25 @@
     }
 
     private static ReviewerSet toReviewerSet(List<ReviewerSetEntryProto> protos) {
-      ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
+      ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Instant> b =
           ImmutableTable.builder();
       for (ReviewerSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Account.id(e.getAccountId()),
-            new Timestamp(e.getTimestampMillis()));
+            Instant.ofEpochMilli(e.getTimestampMillis()));
       }
       return ReviewerSet.fromTable(b.build());
     }
 
     private static ReviewerByEmailSet toReviewerByEmailSet(
         List<ReviewerByEmailSetEntryProto> protos) {
-      ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b =
-          ImmutableTable.builder();
+      ImmutableTable.Builder<ReviewerStateInternal, Address, Instant> b = ImmutableTable.builder();
       for (ReviewerByEmailSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Address.parse(e.getAddress()),
-            new Timestamp(e.getTimestampMillis()));
+            Instant.ofEpochMilli(e.getTimestampMillis()));
       }
       return ReviewerByEmailSet.fromTable(b.build());
     }
@@ -749,7 +748,7 @@
       for (ReviewerStatusUpdateProto proto : protos) {
         b.add(
             ReviewerStatusUpdate.create(
-                new Timestamp(proto.getTimestampMillis()),
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 Account.id(proto.getReviewer()),
                 REVIEWER_STATE_CONVERTER.convert(proto.getState())));
@@ -791,7 +790,7 @@
       for (AssigneeStatusUpdateProto proto : protos) {
         b.add(
             AssigneeStatusUpdate.create(
-                new Timestamp(proto.getTimestampMillis()),
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 proto.getHasCurrentAssignee()
                     ? Optional.of(Account.id(proto.getCurrentAssignee()))
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 44475db..6d49fc8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -17,6 +17,8 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayInputStream;
@@ -33,18 +35,29 @@
 /** Implements the parsing of comment data, handling JSON decoding and push certificates. */
 class ChangeRevisionNote extends RevisionNote<HumanComment> {
   private final ChangeNoteJson noteJson;
-  private final HumanComment.Status status;
+  private final Comment.Status status;
   private String pushCert;
 
-  private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
+  /**
+   * Submit requirement results stored in this revision note. If null, then no SRs were stored in
+   * the revision note . Otherwise, there were stored SRs in this revision note. The list could be
+   * empty, meaning that no SRs were configured for the project.
+   */
+  @Nullable private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
 
   ChangeRevisionNote(
-      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
+      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, Comment.Status status) {
     super(reader, noteId);
     this.noteJson = noteJson;
     this.status = status;
   }
 
+  /**
+   * Returns null if no submit requirements were stored in the revision note. Otherwise, this method
+   * returns a list of submit requirements, which can probably be empty if there were no SRs
+   * configured for the project at the time when the SRs were stored.
+   */
+  @Nullable
   public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
     checkParsed();
     return submitRequirementsResult;
@@ -69,7 +82,7 @@
     }
     this.submitRequirementsResult =
         data.submitRequirementResults == null
-            ? ImmutableList.of()
+            ? null
             : ImmutableList.copyOf(data.submitRequirementResults);
     return data.comments;
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 5acea1b..5d43e28 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -18,29 +18,29 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-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_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-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.ChangeNoteFooters.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.naturalOrder;
@@ -52,9 +52,12 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Table;
 import com.google.common.collect.Table.Cell;
 import com.google.common.collect.TreeBasedTable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -63,6 +66,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
@@ -74,6 +78,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.LabelVote;
@@ -81,10 +86,10 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -115,13 +120,20 @@
  * there is a single author and timestamp for each update.
  *
  * <p>This class is not thread-safe.
+ *
+ * <p>NOTE: This class also serializes the change in a custom storage format, used in NoteDB. All
+ * changes to the storage format must be both forward and backward compatible, see comment on {@link
+ * ChangeNotesParser}.
+ *
+ * <p>Such changes include e.g. introducing/removing footers, modifying footer formats, mutations of
+ * the attached {@link ChangeRevisionNote}.
  */
 public class ChangeUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
+    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Instant when);
 
     ChangeUpdate create(
-        ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
+        ChangeNotes notes, CurrentUser user, Instant when, Comparator<String> labelNameComparator);
   }
 
   private final NoteDbUpdateManager.Factory updateManagerFactory;
@@ -129,13 +141,13 @@
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
   private final ServiceUserClassifier serviceUserClassifier;
+  private final PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator;
 
-  private final Table<String, Account.Id, Optional<Short>> approvals;
+  private final Table<String, Account.Id, Optional<PatchSetApproval>> approvals;
   private final List<PatchSetApproval> copiedApprovals = new ArrayList<>();
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
-  private final List<SubmitRequirementResult> submitRequirementResults = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -169,7 +181,12 @@
   private RobotCommentUpdate robotCommentUpdate;
   private DeleteCommentRewriter deleteCommentRewriter;
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
+  private List<SubmitRequirementResult> submitRequirementResults;
 
+  private ImmutableList.Builder<AttentionSetUpdate> attentionSetUpdatesBuilder =
+      ImmutableList.builder();
+
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -179,9 +196,10 @@
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
       ServiceUserClassifier serviceUserClassifier,
+      PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
-      @Assisted Date when,
+      @Assisted Instant when,
       ChangeNoteUtil noteUtil) {
     this(
         serverIdent,
@@ -190,6 +208,7 @@
         robotCommentUpdateFactory,
         deleteCommentRewriterFactory,
         serviceUserClassifier,
+        patchSetApprovalUuidGenerator,
         notes,
         user,
         when,
@@ -201,7 +220,7 @@
         noteUtil);
   }
 
-  private static Table<String, Account.Id, Optional<Short>> approvals(
+  private static Table<String, Account.Id, Optional<PatchSetApproval>> approvals(
       Comparator<String> nameComparator) {
     return TreeBasedTable.create(nameComparator, naturalOrder());
   }
@@ -214,9 +233,10 @@
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ServiceUserClassifier serviceUserClassifier,
+      PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
-      @Assisted Date when,
+      @Assisted Instant when,
       @Assisted Comparator<String> labelNameComparator,
       ChangeNoteUtil noteUtil) {
     super(notes, user, serverIdent, noteUtil, when);
@@ -225,6 +245,7 @@
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.serviceUserClassifier = serviceUserClassifier;
+    this.patchSetApprovalUuidGenerator = patchSetApprovalUuidGenerator;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -266,7 +287,18 @@
   }
 
   public void putApprovalFor(Account.Id reviewer, String label, short value) {
-    approvals.put(label, reviewer, Optional.of(value));
+    PatchSetApproval psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(getPatchSetId(), reviewer, LabelId.create(label)))
+            .value(value)
+            .granted(when)
+            .uuid(patchSetApprovalUuidGenerator.get(getPatchSetId(), reviewer, label, value, when))
+            .build();
+    approvals.put(label, reviewer, Optional.of(psa));
+  }
+
+  public ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> getApprovals() {
+    return ImmutableTable.copyOf(approvals);
   }
 
   void removeApproval(String label) {
@@ -286,6 +318,23 @@
     copiedApprovals.add(copiedPatchSetApproval);
   }
 
+  public void removeCopiedApprovalFor(
+      @Nullable Account.Id realUserId, Account.Id reviewerId, String label) {
+    PatchSetApproval.Builder psaBuilder =
+        PatchSetApproval.builder()
+            .copied(true)
+            .key(PatchSetApproval.key(getPatchSetId(), reviewerId, LabelId.create(label)))
+            .value(0)
+            .uuid(Optional.empty())
+            .granted(when);
+
+    if (realUserId != null) {
+      psaBuilder.realAccountId(realUserId);
+    }
+
+    copiedApprovals.add(psaBuilder.build());
+  }
+
   public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toString();
@@ -319,10 +368,13 @@
   }
 
   public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) {
+    if (submitRequirementResults == null) {
+      submitRequirementResults = new ArrayList<>();
+    }
     submitRequirementResults.addAll(rs);
   }
 
-  public void putComment(HumanComment.Status status, HumanComment c) {
+  public void putComment(Comment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
     if (status == HumanComment.Status.DRAFT) {
@@ -408,12 +460,21 @@
   }
 
   /**
-   * All updates must have a timestamp of null since we use the commit's timestamp. There also must
-   * not be multiple updates for a single user. Only the first update takes place because of the
-   * different priorities: e.g, if we want to add someone to the attention set but also want to
-   * remove someone from the attention set, we should ensure to add/remove that user based on the
-   * priority of the addition and removal. If most importantly we want to remove the user, then we
-   * must first create the removal, and the addition will not take effect.
+   * Adds attention set updates that should be stored in NoteDb.
+   *
+   * <p>If invoked multiple times with attention set updates for the same user, only the attention
+   * set update of the first invocation is stored for this user and further attention set updates
+   * for this user are silently ignored. This means if callers invoke this method multiple times
+   * with attention set updates for the same user, they must ensure that the first call is being
+   * done with the attention set update that should take precedence.
+   *
+   * @param updates Attention set updates that should be performed. The updates must not have any
+   *     timestamp set ({@link AttentionSetUpdate#timestamp()} must return {@code null}). This is
+   *     because the timestamp of all performed updates is always the timestamp of when the NoteDb
+   *     commit is created. Each of the provided updates must be for a different user, if there are
+   *     multiple updates for the same user the update is rejected.
+   * @throws IllegalArgumentException thrown if any of the provided updates has a timestamp set, or
+   *     if the provided set of updates contains multiple updates for the same user
    */
   public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
     if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
@@ -445,6 +506,10 @@
     addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
   }
 
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return attentionSetUpdatesBuilder.build();
+  }
+
   public void setAssignee(Account.Id assignee) {
     checkArgument(assignee != null, "use removeAssignee");
     this.assignee = Optional.of(assignee);
@@ -506,9 +571,10 @@
   }
 
   /** Returns the tree id for the updated tree */
+  @Nullable
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
-    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
       return null;
     }
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
@@ -518,8 +584,23 @@
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
-    for (SubmitRequirementResult sr : submitRequirementResults) {
-      cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+    if (submitRequirementResults != null) {
+      if (submitRequirementResults.isEmpty()) {
+        ObjectId latestPsCommitId =
+            Iterables.getLast(getNotes().getPatchSets().values()).commitId();
+        cache.get(latestPsCommitId).createEmptySubmitRequirementResults();
+      } else {
+        // Clear any previously stored SRs first. The SRs in this update will overwrite any
+        // previously stored SRs (e.g. if the change is abandoned (SRs stored) -> un-abandoned ->
+        // merged).
+        submitRequirementResults.stream()
+            .map(SubmitRequirementResult::patchSetCommitId)
+            .distinct()
+            .forEach(commit -> cache.get(commit).clearSubmitRequirementResults());
+        for (SubmitRequirementResult sr : submitRequirementResults) {
+          cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+        }
+      }
     }
     if (pushCert != null) {
       checkState(commit != null);
@@ -630,12 +711,12 @@
         deleteCommentRewriter == null && deleteChangeMessageRewriter == null,
         "cannot update and rewrite ref in one BatchUpdate");
 
-    int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
+    PatchSet.Id patchSetId = psId != null ? psId : getChange().currentPatchSetId();
     StringBuilder msg = new StringBuilder();
     if (commitSubject != null) {
       msg.append(commitSubject);
     } else {
-      msg.append("Update patch set ").append(ps);
+      msg.append("Update patch set ").append(patchSetId.get());
     }
     msg.append("\n\n");
 
@@ -644,7 +725,7 @@
       msg.append("\n\n");
     }
 
-    addPatchSetFooter(msg, ps);
+    addPatchSetFooter(msg, patchSetId);
 
     if (currentPatchSet) {
       addFooter(msg, FOOTER_CURRENT, Boolean.TRUE);
@@ -717,7 +798,7 @@
       addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
     }
 
-    for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
+    for (Table.Cell<String, Account.Id, Optional<PatchSetApproval>> c : approvals.cellSet()) {
       addLabelFooter(msg, c);
     }
     for (PatchSetApproval patchSetApproval : copiedApprovals) {
@@ -787,7 +868,10 @@
       }
     }
 
-    updateAttentionSet(msg);
+    boolean hasAttentionSeUpdates = updateAttentionSet(msg);
+    if (isEmptyWithoutAttentionSet() && !hasAttentionSeUpdates) {
+      return NO_OP_UPDATE;
+    }
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -802,36 +886,62 @@
     return cb;
   }
 
-  private void addLabelFooter(StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c) {
+  private void addLabelFooter(
+      StringBuilder msg, Cell<String, Account.Id, Optional<PatchSetApproval>> c) {
     addFooter(msg, FOOTER_LABEL);
+    String label = c.getRowKey();
+    Account.Id reviewerId = c.getColumnKey();
     // Label names/values are safe to append without sanitizing.
-    if (!c.getValue().isPresent()) {
-      msg.append('-').append(c.getRowKey());
+    boolean isRemoval = !c.getValue().isPresent();
+    if (isRemoval) {
+      msg.append('-').append(label);
+      // Since vote removals do not need to be referenced, e.g. by the copy approvals, they do not
+      // require a UUID.
     } else {
-      msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
+      short value = c.getValue().get().value();
+      msg.append(LabelVote.create(label, value).formatWithEquals());
+      msg.append(", ");
+      msg.append(c.getValue().get().uuid().get());
     }
-    Account.Id id = c.getColumnKey();
-    if (!id.equals(getAccountId())) {
-      noteUtil.appendAccountIdIdentString(msg.append(' '), id);
+    if (!reviewerId.equals(getAccountId())) {
+      noteUtil.appendAccountIdIdentString(msg.append(' '), reviewerId);
     }
     msg.append('\n');
   }
 
   private void addCopiedLabelFooter(StringBuilder msg, PatchSetApproval patchSetApproval) {
     if (patchSetApproval.value() == 0) {
-      // Can only happen if we removed a vote. There is no need to persist removed votes.
+      addFooter(msg, FOOTER_COPIED_LABEL);
+
+      // Mark the copied approval as deleted.
+      msg.append('-').append(patchSetApproval.label());
+
+      noteUtil.appendAccountIdIdentString(msg.append(' '), patchSetApproval.accountId());
+
+      // In the non-copied labels, we don't need to pass the real account id since it's already
+      // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
+      if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
+        noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
+      }
+
+      msg.append('\n');
       return;
     }
     addFooter(msg, FOOTER_COPIED_LABEL);
     // Label names/values are safe to append without sanitizing.
     msg.append(
         LabelVote.create(patchSetApproval.label(), patchSetApproval.value()).formatWithEquals());
+    // Might be copied from the vote that was generated before UUID was introduced.
+    if (patchSetApproval.uuid().isPresent()) {
+      msg.append(", ");
+      msg.append(patchSetApproval.uuid().get());
+    }
     Account.Id id = patchSetApproval.accountId();
     noteUtil.appendAccountIdIdentString(msg.append(' '), id);
 
     // In the non-copied labels, we don't need to pass the real account id since it's already
     // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
-    if (patchSetApproval.realAccountId() != null) {
+    if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
       noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
     }
 
@@ -840,6 +950,7 @@
     if (patchSetApproval.tag().isPresent()) {
       msg.append(":\"" + sanitizeFooter(patchSetApproval.tag().get()) + "\"");
     }
+
     msg.append('\n');
   }
 
@@ -863,11 +974,13 @@
       // be submitted or when the caller is a robot.
       return;
     }
+
+    Set<AttentionSetUpdate> updates = new HashSet<>();
     Set<Account.Id> currentReviewers =
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
-    Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
       Account.Id reviewerId = reviewer.getKey();
+
       ReviewerStateInternal reviewerState = reviewer.getValue();
       // Only add new reviewers to the attention set. Also, don't add the owner because the owner
       // can only be a "dummy" reviewer for legacy reasons.
@@ -907,8 +1020,11 @@
    * <p>Changing the behaviour of this method might affect the way a ChangeUpdate is considered to
    * be an "Attention Set Change Only". Make sure the {@link #isAttentionSetChangeOnly} logic is
    * amended as well if needed.
+   *
+   * @return True if one or more attention set updates are appended to the {@code msg}, and false
+   *     otherwise.
    */
-  private void updateAttentionSet(StringBuilder msg) {
+  private boolean updateAttentionSet(StringBuilder msg) {
     if (plannedAttentionSetUpdates == null) {
       plannedAttentionSetUpdates = new HashMap<>();
     }
@@ -934,6 +1050,8 @@
 
     removeInactiveUsersFromAttentionSet(currentReviewers);
 
+    boolean hasUpdates = false;
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -968,7 +1086,10 @@
       }
 
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      attentionSetUpdatesBuilder.add(attentionSetUpdate);
+      hasUpdates = true;
     }
+    return hasUpdates;
   }
 
   private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
@@ -1021,8 +1142,8 @@
     ignoreFurtherAttentionSetUpdates = true;
   }
 
-  private void addPatchSetFooter(StringBuilder sb, int ps) {
-    addFooter(sb, FOOTER_PATCH_SET).append(ps);
+  private void addPatchSetFooter(StringBuilder sb, PatchSet.Id ps) {
+    addFooter(sb, FOOTER_PATCH_SET).append(ps.get());
     if (psState != null) {
       sb.append(" (").append(psState.name().toLowerCase()).append(')');
     }
@@ -1036,6 +1157,10 @@
 
   @Override
   public boolean isEmpty() {
+    return isEmptyWithoutAttentionSet() && plannedAttentionSetUpdates == null;
+  }
+
+  private boolean isEmptyWithoutAttentionSet() {
     return commitSubject == null
         && approvals.isEmpty()
         && copiedApprovals.isEmpty()
@@ -1048,7 +1173,6 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && plannedAttentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
diff --git a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
index 2f47107..e74af5b 100644
--- a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
+++ b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
@@ -16,6 +16,7 @@
 
 import static java.time.format.DateTimeFormatter.ISO_INSTANT;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gson.TypeAdapter;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
@@ -27,7 +28,7 @@
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.format.FormatStyle;
-import java.time.temporal.TemporalAccessor;
+import java.util.Locale;
 
 /**
  * Adapter that reads/writes {@link Timestamp}s as ISO 8601 instant in UTC.
@@ -49,6 +50,16 @@
   private static final DateTimeFormatter FALLBACK =
       DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
 
+  /**
+   * Fixed format to parse date/time in the "Feb 7, 2017 2:20:30 AM" format
+   *
+   * <p>Some old comments (created in Jan-Feb 2017) can be stored in legacy format, which can't be
+   * parsed with {@link #FALLBACK} formatter if the system/default locale has been changed. We will
+   * try to parse with a fixed format if {@link #FALLBACK} doesn't work.
+   */
+  private static final DateTimeFormatter FIXED_FORMAT_FALLBACK =
+      DateTimeFormatter.ofPattern("MMM d, yyyy h:mm:ss a").withLocale(Locale.US);
+
   @Override
   public void write(JsonWriter out, Timestamp ts) throws IOException {
     Timestamp truncated = new Timestamp(ts.getTime() / 1000 * 1000);
@@ -58,12 +69,26 @@
   @Override
   public Timestamp read(JsonReader in) throws IOException {
     String str = in.nextString();
-    TemporalAccessor ta;
     try {
-      ta = ISO_INSTANT.parse(str);
+      return Timestamp.from(Instant.from(ISO_INSTANT.parse(str)));
     } catch (DateTimeParseException e) {
-      ta = LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault());
+      try {
+        return parseDateTimeWithDefaultLocaleFormat(str);
+      } catch (DateTimeParseException e2) {
+        return parseDateTimeWithFixedFormat(str);
+      }
     }
-    return Timestamp.from(Instant.from(ta));
+  }
+
+  public static Timestamp parseDateTimeWithDefaultLocaleFormat(String str) {
+    return Timestamp.from(
+        Instant.from(LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
+  }
+
+  @VisibleForTesting
+  public static Timestamp parseDateTimeWithFixedFormat(String str) {
+    return Timestamp.from(
+        Instant.from(
+            LocalDateTime.from(FIXED_FORMAT_FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 338b984..a67dc07 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -15,16 +15,18 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -69,7 +71,7 @@
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.EditList;
@@ -110,6 +112,8 @@
 public class CommitRewriter {
   /** Options to run {@link #backfillProject}. */
   public static class RunOptions implements Serializable {
+    private static final long serialVersionUID = 1L;
+
     /** Whether to rewrite the commit history or only find refs that need to be fixed. */
     public boolean dryRun = true;
     /**
@@ -123,10 +127,9 @@
     /** Max number of refs to update in a single {@link BatchRefUpdate}. */
     public int maxRefsInBatch = 10000;
     /**
-     * Max number of refs to fix by a single {@link RefsUpdate#backfillProject} run. Since second
-     * run on the same set of refs is a no-op, running with this option in a loop will eventually
-     * fix all refs. Number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch}
-     * option.
+     * Max number of refs to fix by a single {@link RefsUpdate} run. Since the second run on the
+     * same set of refs is a no-op, running with this option in a loop will eventually fix all refs.
+     * The number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch} option.
      */
     public int maxRefsToUpdate = 50000;
   }
@@ -227,6 +230,8 @@
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Splitter COMMIT_MESSAGE_SPLITTER = Splitter.onPattern("\\r?\\n");
+
   private final ChangeNotes.Factory changeNotesFactory;
   private final AccountCache accountCache;
   private final DiffAlgorithm diffAlgorithm = new HistogramDiff();
@@ -263,6 +268,8 @@
     BackfillResult result = new BackfillResult();
     result.ok = true;
     int refsInUpdate = 0;
+
+    @SuppressWarnings("resource")
     RefsUpdate refsUpdate = null;
     try {
       for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
@@ -349,7 +356,7 @@
   private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
     Set<Account.Id> accounts = new HashSet<>();
     accounts.add(changeNotes.getChange().getOwner());
-    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().all().values()) {
       if (patchSetApproval.accountId() != null) {
         accounts.add(patchSetApproval.accountId());
       }
@@ -474,7 +481,7 @@
           }
           detailedVerificationStatus.append("Commit author:\n");
           detailedVerificationStatus.append(fixedAuthorIdent.toString());
-          logger.atWarning().log(detailedVerificationStatus.toString());
+          logger.atWarning().log("%s", detailedVerificationStatus);
         }
       }
       boolean needsFix =
@@ -573,7 +580,7 @@
 
   private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
     return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
-        && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
+        && newIdent.getWhenAsInstant().equals(originalIdent.getWhenAsInstant())
         && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
   }
 
@@ -687,15 +694,16 @@
         || !originalChangeMessage.startsWith(REMOVED_VOTES_CHANGE_MESSAGE_START)) {
       return Optional.empty();
     }
-    String[] lines = originalChangeMessage.split("\\r?\\n");
+    List<String> lines = COMMIT_MESSAGE_SPLITTER.splitToList(originalChangeMessage);
     StringBuilder fixedLines = new StringBuilder();
     boolean anyFixed = false;
-    for (int i = 1; i < lines.length; i++) {
-      if (lines[i].isEmpty()) {
+    for (int i = 1; i < lines.size(); i++) {
+      String line = lines.get(i);
+      if (line.isEmpty()) {
         continue;
       }
-      Matcher matcher = REMOVED_VOTES_CHANGE_MESSAGE_PATTERN.matcher(lines[i]);
-      String replacementLine = lines[i];
+      Matcher matcher = REMOVED_VOTES_CHANGE_MESSAGE_PATTERN.matcher(line);
+      String replacementLine = line;
       if (matcher.matches() && !NON_REPLACE_ACCOUNT_PATTERN.matcher(matcher.group(2)).matches()) {
         anyFixed = true;
         Optional<String> reviewerReplacement =
@@ -766,7 +774,7 @@
     // Pre fix, try to replace with something meaningful.
     // Retrieve reviewer accounts from cache and try to match by their name.
     onAddReviewerMatcher.reset();
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     while (onAddReviewerMatcher.find()) {
       String reviewerName = normalizeOnCodeOwnerAddReviewerMatch(onAddReviewerMatcher.group(1));
       Optional<String> replacementName =
@@ -885,7 +893,7 @@
             commitMessageRange.get().subjectEnd());
     Optional<String> fixedChangeMessage = Optional.empty();
     String originalChangeMessage = null;
-    if (commitMessageRange.isPresent() && commitMessageRange.get().hasChangeMessage()) {
+    if (commitMessageRange.get().hasChangeMessage()) {
       originalChangeMessage =
           RawParseUtils.decode(
                   enc,
@@ -943,7 +951,8 @@
           continue;
         }
       } else if (footerKey.equalsIgnoreCase(FOOTER_LABEL.getName())) {
-        int voterIdentStart = footerValue.indexOf(' ');
+        int uuidStart = footerValue.indexOf(", ");
+        int voterIdentStart = footerValue.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
         FixIdentResult fixedVoter = null;
         if (voterIdentStart > 0) {
           String originalIdentString = footerValue.substring(voterIdentStart + 1);
@@ -1176,7 +1185,8 @@
       // Filter further so we match both email & name
       if (possibleReplacements.size() > 1) {
         logger.atWarning().log(
-            "Fixing ref %s, multiple accounts found with the same email address, while replacing %s",
+            "Fixing ref %s, multiple accounts found with the same email address, while replacing"
+                + " %s",
             changeFixProgress.changeMetaRef, accountInfo);
         possibleReplacements =
             possibleReplacements.entrySet().stream()
@@ -1239,7 +1249,7 @@
       fmt.setContext(0);
       fmt.format(diff, oldBody, newBody);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index e8c0fda..76871a8 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -159,6 +159,7 @@
         HumanComment comment = curMap.get(key);
         if (key.equals(uuid)) {
           comment.message = newMessage;
+          comment.unresolved = false;
         }
         comments.add(comment);
       }
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 1ead03c..c8d93f8 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -15,19 +15,40 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
+import com.google.gerrit.server.util.time.TimeUtil;
 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.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -39,16 +60,21 @@
  * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
  * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
  *
- * <p>An earlier bug in the deletion of draft comments {@code
- * refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain in Git
- * and not get deleted. These refs point to an empty tree.
+ * <p>The implementation has two cases for detecting zombie drafts:
+ *
+ * <ul>
+ *   <li>An earlier bug in the deletion of draft comments {@code
+ *       refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain
+ *       in Git and not get deleted. These refs point to an empty tree. We delete such refs.
+ *   <li>Inspecting all draft-comment refs. Check for each draft if there exists a published comment
+ *       with the same UUID. These comments are called zombie drafts. If the program is run in
+ *       {@link #dryRun} mode, the zombie draft IDs will only be logged for tracking, otherwise they
+ *       will also be deleted.
+ * </uL>
  */
 public class DeleteZombieCommentsRefs {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
-  private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-
   // Number of refs deleted at once in a batch ref-update.
   // Log progress after deleting every CHUNK_SIZE refs
   private static final int CHUNK_SIZE = 3000;
@@ -56,19 +82,73 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
-  private Repository allUsersRepo;
+
+  /**
+   * Run the logic in dry run mode only. That is, detected zombie drafts will be logged only but not
+   * deleted. Creators of this class can use {@link Factory#create(int, boolean)} to specify the dry
+   * run mode. If {@link Factory#create(int)} is used, the dry run mode will be set to its default:
+   * true.
+   */
+  private final boolean dryRun;
+
   private final Consumer<String> uiConsumer;
+  @Nullable private final DraftCommentNotes.Factory draftNotesFactory;
+  @Nullable private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final CommentsUtil commentsUtil;
+  @Nullable private final ChangeUpdate.Factory changeUpdateFactory;
+  @Nullable private final IdentifiedUser.GenericFactory userFactory;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
+
+    DeleteZombieCommentsRefs create(int cleanupPercentage, boolean dryRun);
   }
 
-  @Inject
+  @AssistedInject
   public DeleteZombieCommentsRefs(
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
       @Assisted Integer cleanupPercentage) {
-    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ true,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
+  }
+
+  @AssistedInject
+  public DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted Integer cleanupPercentage,
+      @Assisted boolean dryRun) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        dryRun,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
   }
 
   public DeleteZombieCommentsRefs(
@@ -76,43 +156,252 @@
       GitRepositoryManager repoManager,
       Integer cleanupPercentage,
       Consumer<String> uiConsumer) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ false,
+        uiConsumer,
+        null,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  private DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      boolean dryRun,
+      Consumer<String> uiConsumer,
+      @Nullable ChangeNotes.Factory changeNotesFactory,
+      @Nullable DraftCommentNotes.Factory draftNotesFactory,
+      @Nullable CommentsUtil commentsUtil,
+      @Nullable ChangeUpdate.Factory changeUpdateFactory,
+      @Nullable IdentifiedUser.GenericFactory userFactory) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
+    this.dryRun = dryRun;
     this.uiConsumer = uiConsumer;
+    this.draftNotesFactory = draftNotesFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentsUtil = commentsUtil;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.userFactory = userFactory;
   }
 
   public void execute() throws IOException {
-    allUsersRepo = repoManager.openRepository(allUsers);
-
-    List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
-    List<Ref> zombieRefs = filterZombieRefs(draftRefs);
-
-    logInfo(
-        String.format(
-            "Found a total of %d zombie draft refs in %s repo.",
-            zombieRefs.size(), allUsers.get()));
-
-    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
-    zombieRefs =
-        zombieRefs.stream()
-            .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
-            .collect(toImmutableList());
-    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
-
-    long zombieRefsCnt = zombieRefs.size();
-    long deletedRefsCnt = 0;
-    long startTime = System.currentTimeMillis();
-
-    for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
-      deleteBatchZombieRefs(refsBatch);
-      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
-      deletedRefsCnt += refsBatch.size();
-      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+    deleteDraftRefsThatPointToEmptyTree();
+    if (draftNotesFactory != null) {
+      deleteDraftCommentsThatAreAlsoPublished();
     }
   }
 
-  private void deleteBatchZombieRefs(List<Ref> refsBatch) throws IOException {
+  private void deleteDraftRefsThatPointToEmptyTree() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, draftRefs);
+
+      logInfo(
+          String.format(
+              "Found a total of %d zombie draft refs in %s repo.",
+              zombieRefs.size(), allUsers.get()));
+
+      logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
+      zombieRefs =
+          zombieRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
+
+      if (dryRun) {
+        logInfo(
+            "Running in dry run mode. Skipping deletion of draft refs pointing to an empty tree.");
+        return;
+      }
+
+      long zombieRefsCnt = zombieRefs.size();
+      long deletedRefsCnt = 0;
+      long startTime = System.currentTimeMillis();
+
+      for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
+        deleteBatchZombieRefs(allUsersRepo, refsBatch);
+        long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+        deletedRefsCnt += refsBatch.size();
+        logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+      }
+    }
+  }
+
+  /**
+   * Iterates over all draft refs in All-Users repository. For each draft ref, checks if there
+   * exists a published comment with the same UUID and deletes the draft ref if that's the case
+   * because it is a zombie draft.
+   *
+   * @return the number of detected and deleted zombie draft comments.
+   */
+  @VisibleForTesting
+  public int deleteDraftCommentsThatAreAlsoPublished() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      Timestamp earliestZombieTs = null;
+      Timestamp latestZombieTs = null;
+      int numZombies = 0;
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      // Filter the number of draft refs to be processed according to the cleanup percentage.
+      draftRefs =
+          draftRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
+      ImmutableSet<Change.Id> changeIds =
+          draftRefs.stream()
+              .map(d -> Change.Id.fromAllUsersRef(d.getName()))
+              .collect(ImmutableSet.toImmutableSet());
+      Map<Change.Id, Project.NameKey> changeProjectMap = mapChangeIdsToProjects(changeIds);
+      for (Ref draftRef : draftRefs) {
+        try {
+          Change.Id changeId = Change.Id.fromAllUsersRef(draftRef.getName());
+          Account.Id accountId = Account.Id.fromRef(draftRef.getName());
+          ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId);
+          if (!visitedSet.add(changeUserIDsPair)) {
+            continue;
+          }
+          if (!changeProjectMap.containsKey(changeId)) {
+            logger.atWarning().log(
+                "Could not find a project associated with change ID %s. Skipping draft ref %s.",
+                changeId, draftRef.getName());
+            continue;
+          }
+          DraftCommentNotes draftNotes = draftNotesFactory.create(changeId, accountId).load();
+          ChangeNotes notes =
+              changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
+          List<HumanComment> drafts = draftNotes.getComments().values().asList();
+          List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
+          Set<String> publishedIds = toUuid(published);
+          List<HumanComment> zombieDrafts =
+              drafts.stream()
+                  .filter(draft -> publishedIds.contains(draft.key.uuid))
+                  .collect(Collectors.toList());
+          for (HumanComment zombieDraft : zombieDrafts) {
+            earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
+            latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
+          }
+          zombieDrafts.forEach(
+              zombieDraft ->
+                  logger.atWarning().log(
+                      "Draft comment with uuid '%s' of change %s, account %s, written on %s,"
+                          + " is a zombie draft that is already published.",
+                      zombieDraft.key.uuid, changeId, accountId, zombieDraft.writtenOn));
+          if (!zombieDrafts.isEmpty() && !dryRun) {
+            deleteZombieComments(accountId, notes, zombieDrafts);
+          }
+          numZombies += zombieDrafts.size();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log("Failed to process ref %s", draftRef.getName());
+        }
+      }
+      if (numZombies > 0) {
+        logger.atWarning().log(
+            "Detected %d additional zombie drafts (earliest at %s, latest at %s).",
+            numZombies, earliestZombieTs, latestZombieTs);
+      }
+      return numZombies;
+    }
+  }
+
+  @AutoValue
+  abstract static class ChangeUserIDsPair {
+    abstract Change.Id changeId();
+
+    abstract Account.Id accountId();
+
+    static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) {
+      return new AutoValue_DeleteZombieCommentsRefs_ChangeUserIDsPair(changeId, accountId);
+    }
+  }
+
+  /**
+   * Accepts a list of draft (zombie) comments for the same change and delete them by executing a
+   * {@link ChangeUpdate} on NoteDb. The update is executed using the user account who created this
+   * draft.
+   */
+  private void deleteZombieComments(
+      Account.Id accountId, ChangeNotes changeNotes, List<HumanComment> draftsToDelete)
+      throws IOException {
+    if (changeUpdateFactory == null || userFactory == null) {
+      return;
+    }
+    ChangeUpdate changeUpdate =
+        changeUpdateFactory.create(changeNotes, userFactory.create(accountId), TimeUtil.now());
+    draftsToDelete.forEach(c -> changeUpdate.deleteComment(c));
+    changeUpdate.commit();
+    logger.atInfo().log(
+        "Deleted zombie draft comments with UUIDs %s",
+        draftsToDelete.stream().map(d -> d.key.uuid).collect(Collectors.toList()));
+  }
+
+  /**
+   * Map each change ID to its associated project.
+   *
+   * <p>When doing a ref scan of draft refs
+   * "refs/draft-comments/$change_id_short/$change_id/$user_id" we don't know which project this
+   * draft comment is associated with. The project name is needed to load published comments for the
+   * change, hence we map each change ID to its project here by scanning through the change meta ref
+   * of the change ID in all projects.
+   */
+  private Map<Change.Id, Project.NameKey> mapChangeIdsToProjects(
+      ImmutableSet<Change.Id> changeIds) {
+    Map<Change.Id, Project.NameKey> result = new HashMap<>();
+    for (Project.NameKey project : repoManager.list()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        SetView<Change.Id> unmappedChangeIds = Sets.difference(changeIds, result.keySet());
+        for (Change.Id changeId : unmappedChangeIds) {
+          Ref ref = repo.getRefDatabase().exactRef(RefNames.changeMetaRef(changeId));
+          if (ref != null) {
+            result.put(changeId, project);
+          }
+        }
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log("Failed to open repository for project '%s'.", project);
+      }
+      if (changeIds.size() == result.size()) {
+        // We do not need to scan the remaining repositories
+        break;
+      }
+    }
+    if (result.size() != changeIds.size()) {
+      logger.atWarning().log(
+          "Failed to associate the following change Ids to a project: %s",
+          Sets.difference(changeIds, result.keySet()));
+    }
+    return result;
+  }
+
+  /** Map the list of input comments to their UUIDs. */
+  private Set<String> toUuid(List<HumanComment> in) {
+    return in.stream().map(c -> c.key.uuid).collect(Collectors.toSet());
+  }
+
+  private Timestamp getEarlierTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.before(t2) ? t1 : t2;
+  }
+
+  private Timestamp getLaterTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.after(t2) ? t1 : t2;
+  }
+
+  private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
+      throws IOException {
     List<ReceiveCommand> deleteCommands =
         refsBatch.stream()
             .map(
@@ -126,22 +415,23 @@
     RefUpdateUtil.executeChecked(bru, allUsersRepo);
   }
 
-  private List<Ref> filterZombieRefs(List<Ref> allDraftRefs) throws IOException {
+  private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
+      throws IOException {
     List<Ref> zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
     for (Ref ref : allDraftRefs) {
-      if (isZombieRef(ref)) {
+      if (isZombieRef(allUsersRepo, ref)) {
         zombieRefs.add(ref);
       }
     }
     return zombieRefs;
   }
 
-  private boolean isZombieRef(Ref ref) throws IOException {
-    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
+  private boolean isZombieRef(Repository allUsersRepo, Ref ref) throws IOException {
+    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
   }
 
   private void logInfo(String message) {
-    logger.atInfo().log(message);
+    logger.atInfo().log("%s", message);
     uiConsumer.accept(message);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 9b403e8..bdfe378 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -20,8 +20,6 @@
 
 import com.google.common.annotations.VisibleForTesting;
 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.entities.Account;
@@ -59,7 +57,7 @@
   }
 
   DraftCommentNotes(Args args, Change.Id changeId, Account.Id author, @Nullable Ref ref) {
-    super(args, changeId);
+    super(args, changeId, null);
     this.author = requireNonNull(author);
     this.ref = ref;
     if (ref != null) {
@@ -124,13 +122,13 @@
             reader,
             NoteMap.read(reader, tipCommit),
             HumanComment.Status.DRAFT);
-    ListMultimap<ObjectId, HumanComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ImmutableListMultimap.Builder<ObjectId, HumanComment> cs = ImmutableListMultimap.builder();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (HumanComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
-    comments = ImmutableListMultimap.copyOf(cs);
+    comments = cs.build();
   }
 
   @Override
@@ -143,6 +141,7 @@
     return args.allUsers;
   }
 
+  @Nullable
   @VisibleForTesting
   NoteMap getNoteMap() {
     return revisionNoteMap != null ? revisionNoteMap.noteMap : null;
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 9345d98..0939ada 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -18,14 +18,18 @@
 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.ImmutableListMultimap.flatteningToImmutableListMultimap;
 import static com.google.gerrit.server.logging.TraceContext.newTimer;
 
 import com.google.common.collect.ImmutableList;
+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.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectChangeKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
@@ -73,7 +77,7 @@
 public class NoteDbUpdateManager implements AutoCloseable {
   private static final int MAX_UPDATES_DEFAULT = 1000;
   /** Limits the number of patch sets that can be created. Can be overridden in the config. */
-  private static final int MAX_PATCH_SETS_DEFAULT = 1500;
+  private static final int MAX_PATCH_SETS_DEFAULT = 1000;
 
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
@@ -353,6 +357,15 @@
     }
   }
 
+  public ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates() {
+    return this.changeUpdates.values().stream()
+        .collect(
+            flatteningToImmutableListMultimap(
+                cu -> ProjectChangeKey.create(cu.getProjectName(), cu.getId()),
+                cu -> cu.getAttentionSetUpdates().stream()));
+  }
+
+  @Nullable
   private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
       throws IOException {
     if (or == null || or.cmds.isEmpty()) {
@@ -377,7 +390,7 @@
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     bru.setAtomic(true);
     or.cmds.addTo(bru);
-    bru.setAllowNonFastForwards(true);
+    bru.setAllowNonFastForwards(allowNonFastForwards(or.cmds));
     for (BatchUpdateListener listener : batchUpdateListeners) {
       bru = listener.beforeUpdateRefs(bru);
     }
@@ -458,4 +471,27 @@
       }
     }
   }
+
+  /**
+   * Returns true if we should allow non-fast-forwards while performing the batch ref update. Non-ff
+   * updates are necessary in some specific cases:
+   *
+   * <p>1. Draft ref updates are non fast-forward, since the ref always points to a single commit
+   * that has no parents.
+   *
+   * <p>2. NoteDb rewriters.
+   *
+   * <p>3. If any of the receive commands is of type {@link
+   * org.eclipse.jgit.transport.ReceiveCommand.Type#UPDATE_NONFASTFORWARD} (for example due to a
+   * force push).
+   *
+   * <p>Note that we don't need to explicitly allow non fast-forward updates for DELETE commands
+   * since JGit forces the update implicitly in this case.
+   */
+  private boolean allowNonFastForwards(ChainedReceiveCommands receiveCommands) {
+    return !draftUpdates.isEmpty()
+        || !rewriters.isEmpty()
+        || receiveCommands.getCommands().values().stream()
+            .anyMatch(cmd -> cmd.getType().equals(ReceiveCommand.Type.UPDATE_NONFASTFORWARD));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 396e29b..2ad89b2 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -18,10 +18,15 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.GitDateFormatter;
 import org.eclipse.jgit.util.GitDateFormatter.Format;
@@ -48,6 +53,45 @@
     return Optional.empty();
   }
 
+  /**
+   * Returns an AccountId for the given email address and the current serverId. Reverse lookup the
+   * AccountId using the ExternalIdCache if the account has a foreign serverId.
+   *
+   * @param ident the accountId@serverId identity
+   * @param serverId the Gerrit's serverId
+   * @param externalIdCache reference to the cache for looking up the external ids
+   * @return a defined accountId if the account was found, {@link Account#UNKNOWN_ACCOUNT_ID} if the
+   *     lookup via external-id did not return any account, or an empty value if the identity was
+   *     malformed.
+   * @throws ConfigInvalidException when the lookup of the external-id failed
+   */
+  public static Optional<Account.Id> parseIdent(
+      PersonIdent ident, String serverId, ExternalIdCache externalIdCache)
+      throws ConfigInvalidException {
+    String email = ident.getEmailAddress();
+    int at = email.indexOf('@');
+    if (at >= 0) {
+      Integer id = Ints.tryParse(email.substring(0, at));
+      String accountServerId = email.substring(at + 1);
+      if (id != null) {
+        if (accountServerId.equals(serverId)) {
+          return Optional.of(Account.id(id));
+        }
+
+        ExternalId.Key extIdKey = ExternalId.Key.create(ExternalId.SCHEME_IMPORTED, email, false);
+        try {
+          return externalIdCache
+              .byKey(extIdKey)
+              .map(ExternalId::accountId)
+              .or(() -> Optional.of(Account.UNKNOWN_ACCOUNT_ID));
+        } catch (IOException e) {
+          throw new ConfigInvalidException("Unable to lookup external id from cache", e);
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
   public static String extractHostPartFromPersonIdent(PersonIdent ident) {
     String email = ident.getEmailAddress();
     int at = email.indexOf('@');
@@ -68,6 +112,7 @@
    * Returns the name of the REST API handler that is in the stack trace of the caller of this
    * method.
    */
+  @Nullable
   static String guessRestApiHandler() {
     StackTraceElement[] trace = Thread.currentThread().getStackTrace();
     int i = findRestApiServlet(trace);
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 47e12ff..d743921 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -340,4 +340,16 @@
       counterLock.unlock();
     }
   }
+
+  /**
+   * Retrieves the last returned sequence number.
+   *
+   * <p>Explicitly calls {@link #next()} if this instance didn't return sequence number until now.
+   */
+  public int last() {
+    if (counter == 0) {
+      next();
+    }
+    return counter - 1;
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 7998476..35a014c 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayOutputStream;
@@ -73,7 +74,13 @@
   final Map<Comment.Key, Comment> put;
   private final Set<Comment.Key> delete;
 
-  private List<SubmitRequirementResult> submitRequirementResults;
+  /**
+   * Submit requirement results to be stored in the revision note. If this field is null, we don't
+   * store results in the revision note. Otherwise, we store a "submit requirements" section in the
+   * revision note even if it's empty.
+   */
+  @Nullable private List<SubmitRequirementResult> submitRequirementResults;
+
   private String pushCert;
 
   private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
@@ -83,6 +90,7 @@
       put = Maps.newHashMapWithExpectedSize(baseComments.size());
       if (base instanceof ChangeRevisionNote) {
         pushCert = ((ChangeRevisionNote) base).getPushCert();
+        submitRequirementResults = ((ChangeRevisionNote) base).getSubmitRequirementsResult();
       }
     } else {
       baseRaw = new byte[0];
@@ -90,7 +98,6 @@
       put = new HashMap<>();
       pushCert = null;
     }
-    submitRequirementResults = new ArrayList<>();
     delete = new HashSet<>();
   }
 
@@ -109,7 +116,22 @@
     put.put(comment.key, comment);
   }
 
+  /**
+   * Call this method to designate that we should store submit requirement results in the revision
+   * note. Even if no results are added, an empty submit requirements section will be added.
+   */
+  void createEmptySubmitRequirementResults() {
+    submitRequirementResults = new ArrayList<>();
+  }
+
+  void clearSubmitRequirementResults() {
+    submitRequirementResults = null;
+  }
+
   void putSubmitRequirementResult(SubmitRequirementResult result) {
+    if (submitRequirementResults == null) {
+      submitRequirementResults = new ArrayList<>();
+    }
     submitRequirementResults.add(result);
   }
 
@@ -140,19 +162,19 @@
 
   private void buildNoteJson(ChangeNoteJson noteUtil, OutputStream out) throws IOException {
     ListMultimap<Integer, Comment> comments = buildCommentMap();
-    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
       return;
     }
 
     RevisionNoteData data = new RevisionNoteData();
     data.comments = COMMENT_ORDER.sortedCopy(comments.values());
     data.pushCert = pushCert;
-    if (!submitRequirementResults.isEmpty()) {
-      data.submitRequirementResults =
-          submitRequirementResults.stream()
-              .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
-              .collect(Collectors.toList());
-    }
+    data.submitRequirementResults =
+        submitRequirementResults == null
+            ? null
+            : submitRequirementResults.stream()
+                .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
+                .collect(Collectors.toList());
 
     try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
       noteUtil.getGson().toJson(data, osw);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 5a0b67b..98c9873 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.HumanComment;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,7 +41,7 @@
   }
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
-      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, HumanComment.Status status)
+      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
       throws ConfigInvalidException, IOException {
     ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
     for (Note note : noteMap) {
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index fe05643..2ec68f1 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.notedb;
 
 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.entities.Change;
@@ -47,7 +45,7 @@
 
   @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
-    super(args, change.getId());
+    super(args, change.getId(), null);
     this.change = change;
   }
 
@@ -94,13 +92,13 @@
     revisionNoteMap =
         RevisionNoteMap.parseRobotComments(
             args.changeNoteJson, reader, NoteMap.read(reader, tipCommit));
-    ListMultimap<ObjectId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ImmutableListMultimap.Builder<ObjectId, RobotComment> cs = ImmutableListMultimap.builder();
     for (RobotCommentsRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (RobotComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
-    comments = ImmutableListMultimap.copyOf(cs);
+    comments = cs.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 895f378..e1e6305 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -19,6 +19,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -28,9 +29,9 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -57,18 +58,19 @@
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
 
     RobotCommentUpdate create(
         Change change,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
   }
 
   private List<RobotComment> put = new ArrayList<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -77,10 +79,11 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
   }
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -89,7 +92,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
   }
 
@@ -98,6 +101,7 @@
     put.add(c);
   }
 
+  @Nullable
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
index 7ae98778..b42253e 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -55,6 +55,9 @@
   private final RepoSequence changeSeq;
   private final RepoSequence groupSeq;
   private final Timer2<SequenceType, Boolean> nextIdLatency;
+  private final int accountBatchSize;
+  private final int changeBatchSize;
+  private final int groupBatchSize = 1;
 
   @Inject
   public Sequences(
@@ -65,7 +68,7 @@
       AllUsersName allUsers,
       MetricMaker metrics) {
 
-    int accountBatchSize =
+    accountBatchSize =
         cfg.getInt(
             SECTION_NOTEDB,
             NAME_ACCOUNTS,
@@ -80,7 +83,7 @@
             () -> FIRST_ACCOUNT_ID,
             accountBatchSize);
 
-    int changeBatchSize =
+    changeBatchSize =
         cfg.getInt(
             SECTION_NOTEDB,
             NAME_CHANGES,
@@ -95,7 +98,6 @@
             () -> FIRST_CHANGE_ID,
             changeBatchSize);
 
-    int groupBatchSize = 1;
     groupSeq =
         new RepoSequence(
             repoManager,
@@ -147,6 +149,18 @@
     }
   }
 
+  public int changeBatchSize() {
+    return changeBatchSize;
+  }
+
+  public int groupBatchSize() {
+    return groupBatchSize;
+  }
+
+  public int accountBatchSize() {
+    return accountBatchSize;
+  }
+
   public int currentChangeId() {
     return changeSeq.current();
   }
@@ -159,6 +173,18 @@
     return groupSeq.current();
   }
 
+  public int lastChangeId() {
+    return changeSeq.last();
+  }
+
+  public int lastGroupId() {
+    return groupSeq.last();
+  }
+
+  public int lastAccountId() {
+    return accountSeq.last();
+  }
+
   public void setChangeIdValue(int value) {
     changeSeq.storeNew(value);
   }
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
index d128633..2e62fc1 100644
--- a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2021 The Android Open Source Project
+// Copyright (C) 2022 The Android Open 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,58 +14,64 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
-import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
 
 /** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
 public class StoreSubmitRequirementsOp implements BatchUpdateOp {
-  private final ChangeData.Factory changeDataFactory;
-  private final SubmitRequirementsEvaluator evaluator;
-  private final boolean storeRequirementsInNoteDb;
+  private final Collection<SubmitRequirementResult> submitRequirementResults;
+  private final ChangeData changeData;
+  private final OnStoreSubmitRequirementResultModifier onStoreSubmitRequirementResultModifier;
 
   public interface Factory {
-    StoreSubmitRequirementsOp create();
+
+    /**
+     * {@code submitRequirements} are explicitly passed to the operation so that they are evaluated
+     * before the {@link #updateChange} is called.
+     *
+     * <p>This is because the return results of {@link ChangeData#submitRequirements()} depend on
+     * the status of the change, which can be modified by other {@link BatchUpdateOp}, sharing the
+     * same {@link ChangeContext}.
+     */
+    StoreSubmitRequirementsOp create(
+        Collection<SubmitRequirementResult> submitRequirements, ChangeData changeData);
   }
 
   @Inject
   public StoreSubmitRequirementsOp(
-      ChangeData.Factory changeDataFactory,
-      ExperimentFeatures experimentFeatures,
-      SubmitRequirementsEvaluator evaluator) {
-    this.changeDataFactory = changeDataFactory;
-    this.evaluator = evaluator;
-    this.storeRequirementsInNoteDb =
-        experimentFeatures.isFeatureEnabled(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE);
+      OnStoreSubmitRequirementResultModifier onStoreSubmitRequirementResultModifier,
+      @Assisted Collection<SubmitRequirementResult> submitRequirementResults,
+      @Assisted ChangeData changeData) {
+    this.onStoreSubmitRequirementResultModifier = onStoreSubmitRequirementResultModifier;
+    this.submitRequirementResults = submitRequirementResults;
+    this.changeData = changeData;
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws Exception {
-    if (!storeRequirementsInNoteDb) {
-      // Temporarily stop storing submit requirements in NoteDb when the change is merged.
-      return false;
-    }
-    // Create ChangeData using the project/change IDs instead of ctx.getChange(). We do that because
-    // for changes requiring a rebase before submission (e.g. if submit type = RebaseAlways), the
-    // RebaseOp inserts a new patchset that is visible here (via Change#getCurrentPatchset). If we
-    // then try to get ChangeData#currentPatchset it will return null, since it loads patchsets from
-    // NoteDb but tries to find the patchset with the ID of the one just inserted by the rebase op.
-    // Note that this implementation means that, in this case, submit requirement results will be
-    // stored in change notes of the pre last patchset commit. This is fine since submit requirement
-    // results should evaluate to the exact same results for both commits. Additionally, the
-    // pre-last commit is the one for which we displayed the submit requirement results of the last
-    // patchset to the user before it was merged.
-    ChangeData changeData = changeDataFactory.create(ctx.getProject(), ctx.getChange().getId());
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    // We do not want to store submit requirements in NoteDb for legacy submit records
-    update.putSubmitRequirementResults(
-        evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false).values());
-    return !changeData.submitRequirements().isEmpty();
+    List<SubmitRequirementResult> nonLegacySubmitRequirements =
+        submitRequirementResults.stream()
+            // We don't store results for legacy submit requirements in NoteDb. While
+            // surfacing submit requirements for closed changes, we load submit records
+            // from NoteDb and convert them to submit requirement results. See
+            // ChangeData#submitRequirements().
+            .filter(srResult -> !srResult.isLegacy())
+            .map(
+                // Pass to OnStoreSubmitRequirementResultModifier for override
+                srResult ->
+                    onStoreSubmitRequirementResultModifier.modifyResultOnStore(
+                        srResult.submitRequirement(), srResult, changeData, ctx))
+            .collect(Collectors.toList());
+    update.putSubmitRequirementResults(nonLegacySubmitRequirements);
+    return !nonLegacySubmitRequirements.isEmpty();
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
index 3caa4d4..96d3080 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -32,10 +32,16 @@
 
   private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+  private static final FieldDescriptor SR_SUBMITTABILITY_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(3);
   private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
   private static final FieldDescriptor SR_LEGACY_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(6);
+  private static final FieldDescriptor SR_FORCED_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(7);
+  private static final FieldDescriptor SR_HIDDEN_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(8);
 
   @Override
   public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
@@ -46,13 +52,22 @@
     if (r.legacy().isPresent()) {
       builder.setLegacy(r.legacy().get());
     }
+    if (r.forced().isPresent()) {
+      builder.setForced(r.forced().get());
+    }
+    if (r.hidden().isPresent()) {
+      builder.setHidden(r.hidden().get());
+    }
     if (r.applicabilityExpressionResult().isPresent()) {
       builder.setApplicabilityExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
               r.applicabilityExpressionResult().get()));
     }
-    builder.setSubmittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.serialize(r.submittabilityExpressionResult()));
+    if (r.submittabilityExpressionResult().isPresent()) {
+      builder.setSubmittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.submittabilityExpressionResult().get()));
+    }
     if (r.overrideExpressionResult().isPresent()) {
       builder.setOverrideExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
@@ -71,15 +86,23 @@
     if (proto.hasField(SR_LEGACY_FIELD)) {
       builder.legacy(Optional.of(proto.getLegacy()));
     }
+    if (proto.hasField(SR_FORCED_FIELD)) {
+      builder.forced(Optional.of(proto.getForced()));
+    }
+    if (proto.hasField(SR_HIDDEN_FIELD)) {
+      builder.hidden(Optional.of(proto.getHidden()));
+    }
     if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
       builder.applicabilityExpressionResult(
           Optional.of(
               SubmitRequirementExpressionResultSerializer.deserialize(
                   proto.getApplicabilityExpressionResult())));
     }
-    builder.submittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.deserialize(
-            proto.getSubmittabilityExpressionResult()));
+    if (proto.hasField(SR_SUBMITTABILITY_EXPR_RESULT_FIELD)) {
+      builder.submittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.deserialize(
+              proto.getSubmittabilityExpressionResult()));
+    }
     if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
       builder.overrideExpressionResult(
           Optional.of(
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 2529c04..1f4720d 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -145,7 +145,7 @@
       return existingCommit.get();
     }
     counter.increment(OperationType.IN_MEMORY_WRITE);
-    logger.atInfo().log("Computing in-memory AutoMerge for " + merge.name());
+    logger.atInfo().log("Computing in-memory AutoMerge for %s", merge.name());
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
       return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
     }
@@ -169,22 +169,49 @@
       return Optional.empty();
     }
 
-    if (repoView.getRef(RefNames.refsCacheAutomerge(maybeMergeCommit.name())).isPresent()) {
+    String automergeRef = RefNames.refsCacheAutomerge(maybeMergeCommit.name());
+    logger.atFine().log("AutoMerge ref=%s, mergeCommit=%s", automergeRef, maybeMergeCommit.name());
+    if (repoView.getRef(automergeRef).isPresent()) {
       logger.atFine().log("AutoMerge alredy exists");
       return Optional.empty();
     }
 
+    return Optional.of(
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            createAutoMergeCommit(repoView, rw, ins, maybeMergeCommit),
+            automergeRef));
+  }
+
+  /**
+   * Creates an auto merge commit for the provided merge commit.
+   *
+   * <p>Callers are expected to ensure that the provided commit indeed has 2 parents.
+   *
+   * @return An auto-merge commit. Headers of the returned RevCommit are parsed.
+   */
+  ObjectId createAutoMergeCommit(
+      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit mergeCommit) throws IOException {
     ObjectId autoMerge;
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
           createAutoMergeCommit(
-              repoView.getConfig(), rw, ins, maybeMergeCommit, configuredMergeStrategy);
+              repoView.getConfig(), rw, ins, mergeCommit, configuredMergeStrategy);
     }
     counter.increment(OperationType.ON_DISK_WRITE);
     logger.atFine().log("Added %s AutoMerge ref update for commit", autoMerge.name());
-    return Optional.of(
-        new ReceiveCommand(
-            ObjectId.zeroId(), autoMerge, RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+    return autoMerge;
+  }
+
+  Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName) throws IOException {
+    Ref ref = repo.getRefDatabase().exactRef(refName);
+    if (ref != null && ref.getObjectId() != null) {
+      RevObject obj = rw.parseAny(ref.getObjectId());
+      if (obj instanceof RevCommit) {
+        return Optional.of((RevCommit) obj);
+      }
+    }
+    return Optional.empty();
   }
 
   /**
@@ -225,6 +252,7 @@
               merge.getParent(1),
               m.getMergeResults());
     }
+    logger.atFine().log("AutoMerge treeId=%s", treeId.name());
 
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
@@ -243,28 +271,20 @@
       cb.addParentId(p);
     }
 
+    ObjectId commitId = ins.insert(cb);
+    logger.atFine().log("AutoMerge commitId=%s", commitId.name());
+    ins.flush();
+
     if (ins instanceof InMemoryInserter) {
       // When using an InMemoryInserter we need to read back the values from that inserter because
       // they are not available.
       try (ObjectReader tmpReader = ins.newReader();
           RevWalk tmpRw = new RevWalk(tmpReader)) {
-        return tmpRw.parseCommit(ins.insert(cb));
+        return tmpRw.parseCommit(commitId);
       }
     }
 
-    return rw.parseCommit(ins.insert(cb));
-  }
-
-  private Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName)
-      throws IOException {
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      RevObject obj = rw.parseAny(ref.getObjectId());
-      if (obj instanceof RevCommit) {
-        return Optional.of((RevCommit) obj);
-      }
-    }
-    return Optional.empty();
+    return rw.parseCommit(commitId);
   }
 
   private static class NonFlushingWrapper extends ObjectInserter.Filter {
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index ed68dfd..9a103cd 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.entities.RefNames;
 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.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,19 +30,15 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-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.transport.ReceiveCommand;
 
 /** A utility class for computing the base commit / parent for a specific patchset commit. */
 @Singleton
 class BaseCommitUtil {
   private final AutoMerger autoMerger;
-  private final ThreeWayMergeStrategy mergeStrategy;
   private final GitRepositoryManager repoManager;
 
   /** If true, auto-merge results are stored in the repository. */
@@ -52,7 +47,6 @@
   @Inject
   BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
     this.autoMerger = am;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
     this.saveAutomerge = AutoMerger.cacheAutomerge(cfg);
     this.repoManager = repoManager;
   }
@@ -97,6 +91,7 @@
    * @return Returns the parent commit of the commit represented by the commitId parameter. Note
    *     that auto-merge is not supported for commits having more than two parents.
    */
+  @Nullable
   RevObject getParentCommit(
       Repository repo,
       ObjectInserter ins,
@@ -123,69 +118,29 @@
                 "diff against auto-merge commits is only supported if 'change.cacheAutomerge' config is set to true.");
           }
           // TODO(ghareeb): Avoid persisting auto-merge commits.
-          RevCommit autoMerge = createAutoMergeInGitIfNecessary(repo, ins, rw, current);
-          return autoMerge == null ? getAutoMergeFromGit(repo, current) : autoMerge;
+          return getAutoMergeFromGitOrCreate(repo, ins, rw, current);
         }
         return null;
     }
   }
 
   /**
-   * Creates the auto-merge commit in git. If the auto-merge already exists, this does nothing.
-   * Otherwise, the auto-merge is created, persisted in git and the cache-automerge ref is updated
-   * for the merge commit.
+   * Gets the auto-merge commit from git if it already exists. If not, the auto-merge is created,
+   * persisted in git and the cache-automerge ref is updated for the merge commit.
    *
-   * @return null if the auto-merge already exists in git, or the auto-merge {@link RevCommit}
-   *     object otherwise.
+   * @return the auto-merge {@link RevCommit}
    */
-  private RevCommit createAutoMergeInGitIfNecessary(
+  private RevCommit getAutoMergeFromGitOrCreate(
       Repository repo, ObjectInserter ins, RevWalk rw, RevCommit mergeCommit) throws IOException {
-    Optional<ReceiveCommand> receive =
-        autoMerger.createAutoMergeCommitIfNecessary(
-            new RepoView(repo, rw, ins), rw, ins, mergeCommit);
-    if (receive.isPresent()) {
-      ins.flush();
-      return updateRef(repo, rw, receive.get().getRefName(), receive.get().getNewId(), mergeCommit);
+    String refName = RefNames.refsCacheAutomerge(mergeCommit.name());
+    Optional<RevCommit> autoMergeCommit = autoMerger.lookupCommit(repo, rw, refName);
+    if (autoMergeCommit.isPresent()) {
+      return autoMergeCommit.get();
     }
-    return null;
-  }
-
-  private RevCommit getAutoMergeFromGit(Repository repo, RevCommit mergeCommit) throws IOException {
-    try (InMemoryInserter inMemoryIns = new InMemoryInserter(repo);
-        RevWalk inMemoryRw = new RevWalk(inMemoryIns.newReader())) {
-      return autoMerger.lookupFromGitOrMergeInMemory(
-          repo, inMemoryRw, inMemoryIns, mergeCommit, mergeStrategy);
-    }
-  }
-
-  private static RevCommit updateRef(
-      Repository repo, RevWalk rw, String refName, ObjectId autoMergeId, RevCommit merge)
-      throws IOException {
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setNewObjectId(autoMergeId);
-    ru.disableRefLog();
-    switch (ru.update()) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        return rw.parseCommit(autoMergeId);
-      case LOCK_FAILURE:
-        throw new LockFailureException(
-            String.format("Failed to create auto-merge of %s", merge.name()), ru);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      case RENAMED:
-      default:
-        throw new IOException(
-            String.format(
-                "Failed to create auto-merge of %s: Cannot write %s (%s)",
-                merge.name(), refName, ru.getResult()));
-    }
+    ObjectId autoMergeId =
+        autoMerger.createAutoMergeCommit(new RepoView(repo, rw, ins), rw, ins, mergeCommit);
+    ins.flush();
+    return rw.parseCommit(autoMergeId);
   }
 
   private ObjectInserter newInserter(Repository repo) {
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
index ea92a99..5bd8fd4 100644
--- a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch;
 
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index d2da736..a53660a 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch;
 
@@ -18,10 +18,14 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * An interface for all file diff related operations. Clients should use this interface to request:
@@ -56,7 +60,31 @@
    *     an internal error occurred in Git while evaluating the diff.
    */
   Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
+      Project.NameKey project, ObjectId newCommit, int parentNum, DiffOptions diffOptions)
+      throws DiffNotAvailableException;
+
+  /**
+   * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
+   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
+   * cache.
+   *
+   * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
+   * useful in case one the commits is currently being created, that's why the {@code revWalk}
+   * parameter is needed.
+   *
+   * <p>Note that rename detection is disabled for this method.
+   *
+   * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
+   *     old/new file paths and the change type (added, deleted, etc...).
+   */
+  Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      int parentNum,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -72,7 +100,30 @@
    *     diff.
    */
   Map<String, FileDiffOutput> listModifiedFiles(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, DiffOptions diffOptions)
+      throws DiffNotAvailableException;
+
+  /**
+   * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
+   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
+   * cache.
+   *
+   * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
+   * useful in case one the commits is currently being created, that's why the {@code revWalk}
+   * parameter is needed.
+   *
+   * <p>Note that rename detection is disabled for this method.
+   *
+   * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
+   *     old/new file paths and the change type (added, deleted, etc...).
+   */
+  Map<String, ModifiedFile> loadModifiedFiles(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
       throws DiffNotAvailableException;
 
   /**
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 3423b32..86f122e 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch;
 
@@ -47,7 +47,16 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
@@ -57,6 +66,19 @@
 public class DiffOperationsImpl implements DiffOperations {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final ImmutableMap<DiffEntry.ChangeType, Patch.ChangeType> changeTypeMap =
+      ImmutableMap.of(
+          DiffEntry.ChangeType.ADD,
+          Patch.ChangeType.ADDED,
+          DiffEntry.ChangeType.MODIFY,
+          Patch.ChangeType.MODIFIED,
+          DiffEntry.ChangeType.DELETE,
+          Patch.ChangeType.DELETED,
+          DiffEntry.ChangeType.RENAME,
+          Patch.ChangeType.RENAMED,
+          DiffEntry.ChangeType.COPY,
+          Patch.ChangeType.COPIED);
+
   private static final int RENAME_SCORE = 60;
   private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM =
       DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS;
@@ -91,10 +113,11 @@
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent, DiffOptions diffOptions)
+      throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
-      return getModifiedFiles(diffParams);
+      return getModifiedFiles(diffParams, diffOptions);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -102,8 +125,29 @@
   }
 
   @Override
+  public Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      int parentNum,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parentNum);
+      return loadModifiedFilesWithoutCache(project, diffParams, revWalk, repoConfig);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Failed to evaluate the parent/base commit for commit '%s' with parentNum=%d",
+              newCommit, parentNum),
+          e);
+    }
+  }
+
+  @Override
   public Map<String, FileDiffOutput> listModifiedFiles(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, DiffOptions diffOptions)
       throws DiffNotAvailableException {
     DiffParameters params =
         DiffParameters.builder()
@@ -112,7 +156,26 @@
             .baseCommit(oldCommit)
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .build();
-    return getModifiedFiles(params);
+    return getModifiedFiles(params, diffOptions);
+  }
+
+  @Override
+  public Map<String, ModifiedFile> loadModifiedFiles(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException {
+    DiffParameters params =
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .baseCommit(oldCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    return loadModifiedFilesWithoutCache(project, params, revWalk, repoConfig);
   }
 
   @Override
@@ -161,8 +224,8 @@
     return getModifiedFileForKey(key);
   }
 
-  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(DiffParameters diffParams)
-      throws DiffNotAvailableException {
+  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(
+      DiffParameters diffParams, DiffOptions diffOptions) throws DiffNotAvailableException {
     try {
       Project.NameKey project = diffParams.project();
       ObjectId newCommit = diffParams.newCommit();
@@ -211,7 +274,7 @@
                         /* whitespace= */ null))
             .forEach(fileCacheKeys::add);
       }
-      return getModifiedFilesForKeys(fileCacheKeys);
+      return getModifiedFilesForKeys(fileCacheKeys, diffOptions);
     } catch (IOException e) {
       throw new DiffNotAvailableException(e);
     }
@@ -219,7 +282,8 @@
 
   private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key)
       throws DiffNotAvailableException {
-    Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
+    Map<String, FileDiffOutput> diffList =
+        getModifiedFilesForKeys(ImmutableList.of(key), DiffOptions.DEFAULTS);
     return diffList.containsKey(key.newFilePath())
         ? diffList.get(key.newFilePath())
         : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
@@ -230,8 +294,8 @@
    * results, e.g. due to timeouts in the cache loader, this method requests the diff again using
    * the fallback algorithm {@link DiffAlgorithm#HISTOGRAM_NO_FALLBACK}.
    */
-  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
-      throws DiffNotAvailableException {
+  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(
+      List<FileDiffCacheKey> keys, DiffOptions diffOptions) throws DiffNotAvailableException {
     ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
     List<FileDiffCacheKey> fallbackKeys = new ArrayList<>();
 
@@ -260,7 +324,7 @@
       }
     }
     result.addAll(fileDiffCache.getAll(fallbackKeys).values());
-    return mapByFilePath(result.build());
+    return mapByFilePath(result.build(), diffOptions);
   }
 
   /**
@@ -268,11 +332,12 @@
    * represent the old file path for deleted files, or the new path otherwise.
    */
   private ImmutableMap<String, FileDiffOutput> mapByFilePath(
-      ImmutableCollection<FileDiffOutput> fileDiffOutputs) {
+      ImmutableCollection<FileDiffOutput> fileDiffOutputs, DiffOptions diffOptions) {
     ImmutableMap.Builder<String, FileDiffOutput> diffs = ImmutableMap.builder();
 
     for (FileDiffOutput fileDiffOutput : fileDiffOutputs) {
-      if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
+      if (fileDiffOutput.isEmpty()
+          || (diffOptions.skipFilesWithAllEditsDueToRebase() && allDueToRebase(fileDiffOutput))) {
         continue;
       }
       if (fileDiffOutput.changeType() == ChangeType.DELETED) {
@@ -286,8 +351,8 @@
 
   private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
     return fileDiffOutput.allEditsDueToRebase()
-        && (!(fileDiffOutput.changeType() == ChangeType.RENAMED
-            || fileDiffOutput.changeType() == ChangeType.COPIED));
+        && !(fileDiffOutput.changeType() == ChangeType.RENAMED
+            || fileDiffOutput.changeType() == ChangeType.COPIED);
   }
 
   private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
@@ -326,6 +391,53 @@
         .build();
   }
 
+  /** Loads the modified file paths between two commits without inspecting the diff cache. */
+  private static Map<String, ModifiedFile> loadModifiedFilesWithoutCache(
+      Project.NameKey project, DiffParameters diffParams, RevWalk revWalk, Config repoConfig)
+      throws DiffNotAvailableException {
+    ObjectId newCommit = diffParams.newCommit();
+    ObjectId oldCommit = diffParams.baseCommit();
+    try {
+      ObjectReader reader = revWalk.getObjectReader();
+      List<DiffEntry> diffEntries;
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        df.setReader(reader, repoConfig);
+        df.setDetectRenames(false);
+        diffEntries = df.scan(oldCommit.equals(ObjectId.zeroId()) ? null : oldCommit, newCommit);
+      }
+      List<ModifiedFile> modifiedFiles =
+          diffEntries.stream()
+              .map(
+                  entry ->
+                      ModifiedFile.builder()
+                          .changeType(toChangeType(entry.getChangeType()))
+                          .oldPath(getGitPath(entry.getOldPath()))
+                          .newPath(getGitPath(entry.getNewPath()))
+                          .build())
+              .collect(Collectors.toList());
+      return DiffUtil.mergeRewrittenModifiedFiles(modifiedFiles).stream()
+          .collect(ImmutableMap.toImmutableMap(ModifiedFile::getDefaultPath, Function.identity()));
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Failed to compute the modified files for project '%s',"
+                  + " old commit '%s', new commit '%s'.",
+              project, oldCommit.name(), newCommit.name()),
+          e);
+    }
+  }
+
+  private static Optional<String> getGitPath(String path) {
+    return path.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(path);
+  }
+
+  private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+    if (!changeTypeMap.containsKey(changeType)) {
+      throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+    return changeTypeMap.get(changeType);
+  }
+
   @AutoValue
   abstract static class DiffParameters {
     abstract Project.NameKey project();
diff --git a/java/com/google/gerrit/server/patch/DiffOptions.java b/java/com/google/gerrit/server/patch/DiffOptions.java
new file mode 100644
index 0000000..4d54be1
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOptions.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class DiffOptions {
+  public static final DiffOptions DEFAULTS =
+      DiffOptions.builder().skipFilesWithAllEditsDueToRebase(true).build();
+
+  public abstract boolean skipFilesWithAllEditsDueToRebase();
+
+  public static DiffOptions.Builder builder() {
+    return new AutoValue_DiffOptions.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder skipFilesWithAllEditsDueToRebase(boolean value);
+
+    public abstract DiffOptions build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index fcce672..246544b 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -48,8 +48,9 @@
     ObjectId newId = key.toPatchListKey().getNewId();
     Map<String, FileDiffOutput> diffList =
         oldId == null
-            ? diffOperations.listModifiedFilesAgainstParent(project, newId, /* parentNum= */ 0)
-            : diffOperations.listModifiedFiles(project, oldId, newId);
+            ? diffOperations.listModifiedFilesAgainstParent(
+                project, newId, /* parentNum= */ 0, DiffOptions.DEFAULTS)
+            : diffOperations.listModifiedFiles(project, oldId, newId, DiffOptions.DEFAULTS);
     return toDiffSummary(diffList);
   }
 
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 1e88f9f..70a3208 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -15,9 +15,16 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import java.io.IOException;
+import java.util.Comparator;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -29,6 +36,41 @@
 public class DiffUtil {
 
   /**
+   * Return the {@code modifiedFiles} input list while merging rewritten entries.
+   *
+   * <p>Background: In some cases, JGit returns two diff entries (ADDED/DELETED, RENAMED/DELETED,
+   * etc...) for the same file path. This happens e.g. when a file's mode is changed between
+   * patchsets, for example converting a symlink file to a regular file. We identify this case and
+   * return a single modified file with changeType = {@link ChangeType#REWRITE}.
+   */
+  public static ImmutableList<ModifiedFile> mergeRewrittenModifiedFiles(
+      List<ModifiedFile> modifiedFiles) {
+    ImmutableList.Builder<ModifiedFile> result = ImmutableList.builder();
+    ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
+    modifiedFiles.stream()
+        .forEach(
+            f -> {
+              if (f.changeType() == ChangeType.DELETED) {
+                byPath.get(f.oldPath().get()).add(f);
+              } else {
+                byPath.get(f.newPath().get()).add(f);
+              }
+            });
+    for (String path : byPath.keySet()) {
+      List<ModifiedFile> entries = byPath.get(path);
+      if (entries.size() == 1) {
+        result.add(entries.get(0));
+      } else {
+        // More than one. Return a single REWRITE entry.
+        // Convert the first entry (prioritized according to change type enum order) to REWRITE
+        entries.sort(Comparator.comparingInt(o -> o.changeType().ordinal()));
+        result.add(entries.get(0).toBuilder().changeType(ChangeType.REWRITE).build());
+      }
+    }
+    return result.build();
+  }
+
+  /**
    * Returns the Git tree object ID pointed to by the commitId parameter.
    *
    * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java
index 2c98f1a..d0b7ac6 100644
--- a/java/com/google/gerrit/server/patch/FilePathAdapter.java
+++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch.ChangeType;
 import java.util.Optional;
 
@@ -30,6 +31,7 @@
   /**
    * Converts the old file path of the new diff cache output to the old diff cache representation.
    */
+  @Nullable
   public static String getOldPath(Optional<String> oldName, ChangeType changeType) {
     switch (changeType) {
       case DELETED:
diff --git a/java/com/google/gerrit/server/patch/MergeListBuilder.java b/java/com/google/gerrit/server/patch/MergeListBuilder.java
index 433fcad..8964956 100644
--- a/java/com/google/gerrit/server/patch/MergeListBuilder.java
+++ b/java/com/google/gerrit/server/patch/MergeListBuilder.java
@@ -16,13 +16,11 @@
 
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class MergeListBuilder {
-  public static List<RevCommit> build(RevWalk rw, RevCommit merge, int uninterestingParent)
+  public static ImmutableList<RevCommit> build(RevWalk rw, RevCommit merge, int uninterestingParent)
       throws IOException {
     rw.reset();
     rw.parseBody(merge);
@@ -40,11 +38,11 @@
       }
     }
 
-    List<RevCommit> result = new ArrayList<>();
+    ImmutableList.Builder<RevCommit> result = ImmutableList.builder();
     RevCommit c;
     while ((c = rw.next()) != null) {
       result.add(c);
     }
-    return result;
+    return result.build();
   }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index 81355cc..7a8180bd 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -57,8 +57,9 @@
       throws IOException {
     this.repo = repo;
     this.diff =
-        modifiedFiles.values().stream()
-            .filter(f -> f.newPath().isPresent() && f.newPath().get().equals(fileName))
+        modifiedFiles.entrySet().stream()
+            .filter(f -> f.getKey().equals(fileName))
+            .map(Map.Entry::getValue)
             .findFirst()
             .orElse(FileDiffOutput.empty(fileName, ObjectId.zeroId(), ObjectId.zeroId()));
 
@@ -96,7 +97,13 @@
         bTree = null;
       } else {
         if (diff.oldCommitId() != null) {
-          aTree = rw.parseTree(diff.oldCommitId());
+          if (diff.oldCommitId().equals(ObjectId.zeroId())) {
+            // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
+            // parents.
+            aTree = null;
+          } else {
+            aTree = rw.parseTree(diff.oldCommitId());
+          }
         } else {
           final RevCommit p = bCommit.getParent(0);
           rw.parseHeaders(p);
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 33300e3..1612925 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
 import com.google.gerrit.entities.FixReplacement;
@@ -209,6 +210,7 @@
     }
   }
 
+  @Nullable
   private static String oldName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case ADDED:
@@ -224,6 +226,7 @@
     }
   }
 
+  @Nullable
   private static String newName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case DELETED:
@@ -412,6 +415,7 @@
           treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
     }
 
+    @Nullable
     private TreeWalk find(ObjectReader reader, String path, ObjectId within) throws IOException {
       if (path == null || within == null) {
         return null;
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02f125a..3baa3b1 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -175,10 +175,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
 
-    try {
-      permissionBackend.user(currentUser).change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.user(currentUser).change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
index accd2bd..348e244 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
@@ -97,10 +97,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceNotFoundException {
 
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.currentUser().change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 572d73d..7562b49 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -70,6 +70,7 @@
  */
 public class SubmitWithStickyApprovalDiff {
   private static final int HEAP_EST_SIZE = 32 * 1024;
+  private static final int DEFAULT_POST_SUBMIT_SIZE_LIMIT = 300 * 1024; // 300 KiB
 
   private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
@@ -88,6 +89,15 @@
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.repositoryManager = repositoryManager;
+    // (November 2021) We define the max cumulative comment size to 300 KIB since it's a reasonable
+    // size that is large enough for all purposes but not too large to choke the change index by
+    // exceeding the cumulative comment size limit (new comments are not allowed once the limit
+    // is reached). At Google, the change index limit is 5MB, while the cumulative size limit is
+    // set at 3MB. In this example, we can reach at most 3.3MB hence we ensure not to exceed the
+    // limit of 5MB.
+    // The reason we exclude the post submit diff from the cumulative comment size limit is
+    // just because change messages not currently being validated. Change messages are still
+    // counted towards the limit, though.
     maxCumulativeSize =
         serverConfig.getInt(
             "change",
@@ -129,7 +139,9 @@
 
     diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
     TemporaryBuffer.Heap buffer =
-        new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxCumulativeSize), maxCumulativeSize);
+        new TemporaryBuffer.Heap(
+            Math.min(HEAP_EST_SIZE, DEFAULT_POST_SUBMIT_SIZE_LIMIT),
+            DEFAULT_POST_SUBMIT_SIZE_LIMIT);
     try (Repository repository = repositoryManager.openRepository(notes.getProjectName());
         DiffFormatter formatter = new DiffFormatter(buffer)) {
       formatter.setRepository(repository);
@@ -150,6 +162,12 @@
           throw e;
         }
       }
+      if (formatterResult != null) {
+        int addedBytes = formatterResult.stream().mapToInt(String::length).sum();
+        if (!CommentCumulativeSizeValidator.isEnoughSpace(notes, addedBytes, maxCumulativeSize)) {
+          isDiffTooLarge = true;
+        }
+      }
       for (FileDiffOutput fileDiff : modifiedFilesList) {
         diff.append(
             getDiffForFile(
@@ -276,7 +294,7 @@
     ProjectState projectState =
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
     PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
-    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().onlyNonCopied().values()) {
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
@@ -299,7 +317,8 @@
   private Map<String, FileDiffOutput> listModifiedFiles(
       Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
     try {
-      return diffOperations.listModifiedFiles(project, priorPatchSet.commitId(), ps.commitId());
+      return diffOperations.listModifiedFiles(
+          project, priorPatchSet.commitId(), ps.commitId(), DiffOptions.DEFAULTS);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't post diff messsage on submit although "
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
index 76d1710..28c57cb 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.diff;
 
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index 460c2e2..41f2fed 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.diff;
 
@@ -18,14 +18,11 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -40,7 +37,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -86,7 +82,7 @@
             .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
             .maximumWeight(10 << 20)
             .weigher(ModifiedFilesWeigher.class)
-            .version(3)
+            .version(4)
             .loader(ModifiedFilesLoader.class);
       }
     };
@@ -143,14 +139,15 @@
               .bTree(bTree)
               .renameScore(key.renameScore())
               .build();
-      List<ModifiedFile> modifiedFiles = mergeRewrittenEntries(gitCache.get(gitKey));
+      ImmutableList<ModifiedFile> modifiedFiles =
+          DiffUtil.mergeRewrittenModifiedFiles(gitCache.get(gitKey));
       if (key.aCommit().equals(ObjectId.zeroId())) {
-        return ImmutableList.copyOf(modifiedFiles);
+        return modifiedFiles;
       }
       RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
       RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
       if (DiffUtil.areRelated(revCommitA, revCommitB)) {
-        return ImmutableList.copyOf(modifiedFiles);
+        return modifiedFiles;
       }
       Set<String> touchedFiles =
           getTouchedFilesWithParents(
@@ -206,37 +203,5 @@
       // value as the set of file paths shouldn't contain it.
       return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
     }
-
-    /**
-     * Return the {@code modifiedFiles} input list while merging rewritten entries.
-     *
-     * <p>Background: In some cases, JGit returns two diff entries (ADDED/DELETED, RENAMED/DELETED,
-     * etc...) for the same file path. This happens e.g. when a file's mode is changed between
-     * patchsets, for example converting a symlink file to a regular file. We identify this case and
-     * return a single modified file with changeType = {@link ChangeType#REWRITE}.
-     */
-    private static List<ModifiedFile> mergeRewrittenEntries(List<ModifiedFile> modifiedFiles) {
-      List<ModifiedFile> result = new ArrayList<>();
-      ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
-      modifiedFiles.stream()
-          .forEach(
-              f -> {
-                if (f.changeType() == ChangeType.DELETED) {
-                  byPath.get(f.oldPath().get()).add(f);
-                } else {
-                  byPath.get(f.newPath().get()).add(f);
-                }
-              });
-      for (String path : byPath.keySet()) {
-        List<ModifiedFile> entries = byPath.get(path);
-        if (entries.size() == 1) {
-          result.add(entries.get(0));
-        } else {
-          // More than one. Return a single REWRITE entry.
-          result.add(entries.get(0).toBuilder().changeType(ChangeType.REWRITE).build());
-        }
-      }
-      return result;
-    }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
index 512da6f..c569de8 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.diff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/Edit.java b/java/com/google/gerrit/server/patch/filediff/Edit.java
index 4a698a4..a22857f 100644
--- a/java/com/google/gerrit/server/patch/filediff/Edit.java
+++ b/java/com/google/gerrit/server/patch/filediff/Edit.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
index a9bcf03..df8bc09 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 92c3b39..d1bda5c 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.filediff;
 
@@ -97,7 +97,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(8)
+            .version(9)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -443,6 +443,8 @@
                 .patchType(mainGitDiff.patchType())
                 .oldPath(mainGitDiff.oldPath())
                 .newPath(mainGitDiff.newPath())
+                .oldMode(mainGitDiff.oldMode())
+                .newMode(mainGitDiff.newMode())
                 .headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
                 .edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
                 .size(newSize)
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 242c1a4..9286f47 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -1,25 +1,28 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.filediff;
 
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
 import com.google.gerrit.entities.Patch.PatchType;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
@@ -61,6 +64,18 @@
    */
   public abstract Optional<String> newPath();
 
+  /**
+   * The file mode of the old file at the old git tree diff identified by {@link #oldCommitId()}
+   * ()}.
+   */
+  public abstract Optional<Patch.FileMode> oldMode();
+
+  /**
+   * The file mode of the new file at the new git tree diff identified by {@link #newCommitId()}
+   * ()}.
+   */
+  public abstract Optional<Patch.FileMode> newMode();
+
   /** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
   public abstract Patch.ChangeType changeType();
 
@@ -201,6 +216,10 @@
 
     public abstract Builder newPath(Optional<String> value);
 
+    public abstract Builder oldMode(Optional<Patch.FileMode> oldMode);
+
+    public abstract Builder newMode(Optional<Patch.FileMode> newMode);
+
     public abstract Builder changeType(ChangeType value);
 
     public abstract Builder patchType(Optional<PatchType> value);
@@ -221,6 +240,9 @@
   public enum Serializer implements CacheSerializer<FileDiffOutput> {
     INSTANCE;
 
+    private static final Converter<String, FileMode> FILE_MODE_CONVERTER =
+        Enums.stringConverter(Patch.FileMode.class);
+
     private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(1);
 
@@ -233,6 +255,12 @@
     private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(12);
 
+    private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(13);
+
+    private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(14);
+
     @Override
     public byte[] serialize(FileDiffOutput fileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -277,6 +305,13 @@
         builder.setNegative(fileDiff.negative().get());
       }
 
+      if (fileDiff.oldMode().isPresent()) {
+        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.oldMode().get()));
+      }
+      if (fileDiff.newMode().isPresent()) {
+        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.newMode().get()));
+      }
+
       return Protos.toByteArray(builder.build());
     }
 
@@ -318,6 +353,12 @@
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
       }
+      if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
+        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
+      }
+      if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
+        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
+      }
       return builder.build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
index 8eda234..b92ba8e 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
index a009a02..eae78aa 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileEdits.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
index 3720680..74ac18f 100644
--- a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
+++ b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
index d178f22..676b55f 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
index b7144d7..b70f6e1 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitdiff;
 
@@ -82,7 +82,7 @@
             .weigher(GitModifiedFilesWeigher.class)
             // The cache is using the default disk limit as per section cache.<name>.diskLimit
             // in the cache documentation link.
-            .version(1)
+            .version(2)
             .loader(GitModifiedFilesCacheImpl.Loader.class);
       }
     };
@@ -131,6 +131,8 @@
           df.setDetectRenames(true);
           df.getRenameDetector().setRenameScore(key.renameScore());
         }
+        // Skip detecting content renames for binary files.
+        df.getRenameDetector().setSkipContentRenamesForBinaryFiles(true);
         // The scan method only returns the file paths that are different. Callers may choose to
         // format these paths themselves.
         return df.scan(key.aTree().equals(ObjectId.zeroId()) ? null : key.aTree(), key.bTree());
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
index a678379..dfe4cf8 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index f4e7ca3..62d66c0 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -1,20 +1,22 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitdiff;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
@@ -47,6 +49,10 @@
    */
   public abstract Optional<String> newPath();
 
+  public String getDefaultPath() {
+    return newPath().isPresent() ? newPath().get() : oldPath().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_ModifiedFile.Builder();
   }
@@ -80,6 +86,9 @@
   enum Serializer implements CacheSerializer<ModifiedFile> {
     INSTANCE;
 
+    private static final Converter<String, ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(ChangeType.class);
+
     private static final FieldDescriptor oldPathDescriptor =
         ModifiedFileProto.getDescriptor().findFieldByNumber(2);
 
@@ -93,7 +102,7 @@
 
     public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
       ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
-      builder.setChangeType(modifiedFile.changeType().toString());
+      builder.setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(modifiedFile.changeType()));
       if (modifiedFile.oldPath().isPresent()) {
         builder.setOldPath(modifiedFile.oldPath().get());
       }
@@ -111,7 +120,7 @@
 
     public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
       ModifiedFile.Builder builder = ModifiedFile.builder();
-      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+      builder.changeType(CHANGE_TYPE_CONVERTER.convert(modifiedFileProto.getChangeType()));
 
       if (modifiedFileProto.hasField(oldPathDescriptor)) {
         builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
index 7454f81..5b1e343 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 2f23c8c..d0d024c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitfilediff;
 
@@ -18,6 +18,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Patch;
@@ -232,6 +234,15 @@
   public enum Serializer implements CacheSerializer<GitFileDiff> {
     INSTANCE;
 
+    private static final Converter<String, Patch.FileMode> FILE_MODE_CONVERTER =
+        Enums.stringConverter(Patch.FileMode.class);
+
+    private static final Converter<String, Patch.ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.ChangeType.class);
+
+    private static final Converter<String, Patch.PatchType> PATCH_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.PatchType.class);
+
     private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
         GitFileDiffProto.getDescriptor().findFieldByNumber(3);
 
@@ -258,7 +269,7 @@
               .setFileHeader(gitFileDiff.fileHeader())
               .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
               .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
-              .setChangeType(gitFileDiff.changeType().name());
+              .setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(gitFileDiff.changeType()));
       gitFileDiff
           .edits()
           .forEach(
@@ -276,13 +287,13 @@
         builder.setNewPath(gitFileDiff.newPath().get());
       }
       if (gitFileDiff.oldMode().isPresent()) {
-        builder.setOldMode(gitFileDiff.oldMode().get().name());
+        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.oldMode().get()));
       }
       if (gitFileDiff.newMode().isPresent()) {
-        builder.setNewMode(gitFileDiff.newMode().get().name());
+        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.newMode().get()));
       }
       if (gitFileDiff.patchType().isPresent()) {
-        builder.setPatchType(gitFileDiff.patchType().get().name());
+        builder.setPatchType(PATCH_TYPE_CONVERTER.reverse().convert(gitFileDiff.patchType().get()));
       }
       if (gitFileDiff.negative().isPresent()) {
         builder.setNegative(gitFileDiff.negative().get());
@@ -303,7 +314,7 @@
           .fileHeader(proto.getFileHeader())
           .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
           .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
-          .changeType(ChangeType.valueOf(proto.getChangeType()));
+          .changeType(CHANGE_TYPE_CONVERTER.convert(proto.getChangeType()));
 
       if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
         builder.oldPath(Optional.of(proto.getOldPath()));
@@ -312,13 +323,13 @@
         builder.newPath(Optional.of(proto.getNewPath()));
       }
       if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
-        builder.oldMode(Optional.of(Patch.FileMode.valueOf(proto.getOldMode())));
+        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
       }
       if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
-        builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
+        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
       }
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
-        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+        builder.patchType(Optional.of(PATCH_TYPE_CONVERTER.convert(proto.getPatchType())));
       }
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
index 2516761..adaeb90 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index f293a64..43a212c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitfilediff;
 
@@ -28,6 +28,7 @@
 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.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -43,6 +44,7 @@
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.util.git.CloseablePool;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -59,17 +61,16 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.patch.FileHeader;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
@@ -77,6 +78,7 @@
 /** Implementation of the {@link GitFileDiffCache} */
 @Singleton
 public class GitFileDiffCacheImpl implements GitFileDiffCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final String GIT_DIFF = "git_file_diff";
 
   public static Module module() {
@@ -156,16 +158,6 @@
   }
 
   static class Loader extends CacheLoader<GitFileDiffCacheKey, GitFileDiff> {
-    /**
-     * Extractor for the file path from a {@link DiffEntry}. Returns the old file path if the entry
-     * corresponds to a deleted file, otherwise it returns the new file path.
-     */
-    private static final Function<DiffEntry, String> pathExtractor =
-        (DiffEntry entry) ->
-            entry.getChangeType().equals(ChangeType.DELETE)
-                ? entry.getOldPath()
-                : entry.getNewPath();
-
     private final GitRepositoryManager repoManager;
     private final ExecutorService diffExecutor;
     private final long timeoutMillis;
@@ -183,7 +175,7 @@
           ConfigUtil.getTimeUnit(
               cfg,
               "cache",
-              "diff",
+              GIT_DIFF,
               "timeout",
               TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
               TimeUnit.MILLISECONDS);
@@ -218,8 +210,7 @@
                 .collect(Collectors.groupingBy(GitFileDiffCacheKey::project));
 
         for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
-          try (Repository repo = repoManager.openRepository(entry.getKey());
-              ObjectReader reader = repo.newObjectReader()) {
+          try (Repository repo = repoManager.openRepository(entry.getKey())) {
 
             // Grouping keys by diff options because each group of keys will be processed with a
             // separate call to JGit using the DiffFormatter object.
@@ -228,7 +219,7 @@
 
             for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group :
                 optionsGroups.entrySet()) {
-              result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+              result.putAll(loadAllImpl(repo, group.getKey(), group.getValue()));
             }
           }
         }
@@ -243,42 +234,46 @@
      * @return The git file diffs for all input keys.
      */
     private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
-        Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
+        Repository repo, DiffOptions options, List<GitFileDiffCacheKey> keys)
         throws IOException, DiffNotAvailableException {
       ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
           ImmutableMap.builderWithExpectedSize(keys.size());
       Map<GitFileDiffCacheKey, String> filePaths =
           keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
-      DiffFormatter formatter = createDiffFormatter(options, repo, reader);
-      ListMultimap<String, DiffEntry> diffEntries =
-          loadDiffEntries(formatter, options, filePaths.values());
-      for (GitFileDiffCacheKey key : filePaths.keySet()) {
-        String newFilePath = filePaths.get(key);
-        if (!diffEntries.containsKey(newFilePath)) {
-          result.put(
-              key,
-              GitFileDiff.empty(
-                  AbbreviatedObjectId.fromObjectId(key.oldTree()),
-                  AbbreviatedObjectId.fromObjectId(key.newTree()),
-                  newFilePath));
-          continue;
+      try (CloseablePool<DiffFormatter> diffPool =
+          new CloseablePool<>(() -> createDiffFormatter(options, repo))) {
+        ListMultimap<String, DiffEntry> diffEntries;
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          diffEntries = loadDiffEntries(formatter.get(), options, filePaths.values());
         }
-        List<DiffEntry> entries = diffEntries.get(newFilePath);
-        if (entries.size() == 1) {
-          result.put(key, createGitFileDiff(entries.get(0), formatter, key));
-        } else {
-          // Handle when JGit returns two {Added, Deleted} entries for the same file. This happens,
-          // for example, when a file's mode is changed between patchsets (e.g. converting a
-          // symlink to a regular file). We combine both diff entries into a single entry with
-          // {changeType = Rewrite}.
-          List<GitFileDiff> gitDiffs = new ArrayList<>();
-          for (DiffEntry entry : diffEntries.get(newFilePath)) {
-            gitDiffs.add(createGitFileDiff(entry, formatter, key));
+        for (GitFileDiffCacheKey key : filePaths.keySet()) {
+          String newFilePath = filePaths.get(key);
+          if (!diffEntries.containsKey(newFilePath)) {
+            result.put(
+                key,
+                GitFileDiff.empty(
+                    AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                    AbbreviatedObjectId.fromObjectId(key.newTree()),
+                    newFilePath));
+            continue;
           }
-          result.put(key, createRewriteEntry(gitDiffs));
+          List<DiffEntry> entries = diffEntries.get(newFilePath);
+          if (entries.size() == 1) {
+            result.put(key, createGitFileDiff(entries.get(0), key, diffPool));
+          } else {
+            // Handle when JGit returns two {Added, Deleted} entries for the same file. This
+            // happens, for example, when a file's mode is changed between patchsets (e.g.
+            // converting a symlink to a regular file). We combine both diff entries into a single
+            // entry with {changeType = Rewrite}.
+            List<GitFileDiff> gitDiffs = new ArrayList<>();
+            for (DiffEntry entry : diffEntries.get(newFilePath)) {
+              gitDiffs.add(createGitFileDiff(entry, key, diffPool));
+            }
+            result.put(key, createRewriteEntry(gitDiffs));
+          }
         }
+        return result.build();
       }
-      return result.build();
     }
 
     private static ListMultimap<String, DiffEntry> loadDiffEntries(
@@ -291,18 +286,17 @@
               diffOptions.newTree());
 
       return diffEntries.stream()
-          .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
+          .filter(d -> filePathsSet.contains(extractPath(d)))
           .collect(
               Multimaps.toMultimap(
-                  d -> pathExtractor.apply(d),
+                  Loader::extractPath,
                   identity(),
                   MultimapBuilder.treeKeys().arrayListValues()::build));
     }
 
-    private static DiffFormatter createDiffFormatter(
-        DiffOptions diffOptions, Repository repo, ObjectReader reader) {
+    private static DiffFormatter createDiffFormatter(DiffOptions diffOptions, Repository repo) {
       try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(reader, repo.getConfig());
+        diffFormatter.setRepository(repo);
         RawTextComparator cmp = comparatorFor(diffOptions.whitespace());
         diffFormatter.setDiffComparator(cmp);
         if (diffOptions.renameScore() != -1) {
@@ -345,25 +339,30 @@
      *       timeout enforcement.
      */
     private GitFileDiff createGitFileDiff(
-        DiffEntry diffEntry, DiffFormatter formatter, GitFileDiffCacheKey key) throws IOException {
+        DiffEntry diffEntry, GitFileDiffCacheKey key, CloseablePool<DiffFormatter> diffPool)
+        throws IOException {
       if (!key.useTimeout()) {
-        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
-        return GitFileDiff.create(diffEntry, fileHeader);
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
+        }
       }
-      Future<FileHeader> fileHeaderFuture =
+      // This submits the DiffFormatter to a different thread. The CloseablePool and our usage of it
+      // ensures that any DiffFormatter instance and the ObjectReader it references internally is
+      // only used by a single thread concurrently. However, ObjectReaders have a reference to
+      // Repository which might not be thread safe (FileRepository is, DfsRepository might not).
+      // This could lead to a race condition.
+      Future<GitFileDiff> fileDiffFuture =
           diffExecutor.submit(
               () -> {
-                synchronized (diffEntry) {
-                  return formatter.toFileHeader(diffEntry);
+                try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+                  return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
                 }
               });
       try {
         // We employ the timeout because of a bug in Myers diff in JGit. See
         // bugs.chromium.org/p/gerrit/issues/detail?id=487 for more details. The bug may happen
         // if the algorithm used in diffs is HISTOGRAM_WITH_FALLBACK_MYERS.
-        fileHeaderFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
-        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
-        return GitFileDiff.create(diffEntry, fileHeader);
+        return fileDiffFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
       } catch (InterruptedException | TimeoutException e) {
         // If timeout happens, create a negative result
         metrics.timeouts.increment();
@@ -378,6 +377,56 @@
         throw new IOException(e.getMessage(), e.getCause());
       }
     }
+
+    /**
+     * Extract the file path from a {@link DiffEntry}. Returns the old file path if the entry
+     * corresponds to a deleted file, otherwise it returns the new file path.
+     */
+    private static String extractPath(DiffEntry diffEntry) {
+      return diffEntry.getChangeType().equals(ChangeType.DELETE)
+          ? diffEntry.getOldPath()
+          : diffEntry.getNewPath();
+    }
+
+    private FileHeader getFileHeader(
+        CloseablePool<DiffFormatter>.Handle formatter, DiffEntry diffEntry) throws IOException {
+      logger.atFine().log("getting file header for %s", formatDiffEntryForLogging(diffEntry));
+      try {
+        return formatter.get().toFileHeader(diffEntry);
+      } catch (MissingObjectException e) {
+        throw new IOException(
+            String.format("Failed to get file header for %s", formatDiffEntryForLogging(diffEntry)),
+            e);
+      }
+    }
+
+    private String formatDiffEntryForLogging(DiffEntry diffEntry) {
+      StringBuilder buf = new StringBuilder();
+      buf.append("DiffEntry[");
+      buf.append(diffEntry.getChangeType());
+      buf.append(" ");
+      switch (diffEntry.getChangeType()) {
+        case ADD:
+          buf.append(String.format("%s (%s)", diffEntry.getNewPath(), diffEntry.getNewId().name()));
+          break;
+        case COPY:
+        case RENAME:
+          buf.append(
+              String.format(
+                  "%s (%s) -> %s (%s)",
+                  diffEntry.getOldPath(),
+                  diffEntry.getOldId().name(),
+                  diffEntry.getNewPath(),
+                  diffEntry.getNewId().name()));
+          break;
+        case DELETE:
+        case MODIFY:
+          buf.append(String.format("%s (%s)", diffEntry.getOldPath(), diffEntry.getOldId().name()));
+          break;
+      }
+      buf.append("]");
+      return buf.toString();
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
index 2d80614..2e18e93 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -104,6 +106,12 @@
   public enum Serializer implements CacheSerializer<GitFileDiffCacheKey> {
     INSTANCE;
 
+    private static final Converter<String, DiffAlgorithm> DIFF_ALGORITHM_CONVERTER =
+        Enums.stringConverter(DiffAlgorithm.class);
+
+    private static final Converter<String, Whitespace> WHITESPACE_CONVERTER =
+        Enums.stringConverter(Whitespace.class);
+
     @Override
     public byte[] serialize(GitFileDiffCacheKey key) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -114,8 +122,8 @@
               .setBTree(idConverter.toByteString(key.newTree()))
               .setFilePath(key.newFilePath())
               .setRenameScore(key.renameScore())
-              .setDiffAlgorithm(key.diffAlgorithm().name())
-              .setWhitepsace(key.whitespace().name())
+              .setDiffAlgorithm(DIFF_ALGORITHM_CONVERTER.reverse().convert(key.diffAlgorithm()))
+              .setWhitepsace(WHITESPACE_CONVERTER.reverse().convert(key.whitespace()))
               .setUseTimeout(key.useTimeout())
               .build());
     }
@@ -130,8 +138,8 @@
           .newTree(idConverter.fromByteString(proto.getBTree()))
           .newFilePath(proto.getFilePath())
           .renameScore(proto.getRenameScore())
-          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
-          .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .diffAlgorithm(DIFF_ALGORITHM_CONVERTER.convert(proto.getDiffAlgorithm()))
+          .whitespace(WHITESPACE_CONVERTER.convert(proto.getWhitepsace()))
           .useTimeout(proto.getUseTimeout())
           .build();
     }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
index 47f7791..7651517 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
-//  Licensed under the Apache License, Version 2.0 (the "License");
-//  you may not use this file except in compliance with the License.
-//  You may obtain a copy of the License at
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
 //
-//  http://www.apache.org/licenses/LICENSE-2.0
+// http://www.apache.org/licenses/LICENSE-2.0
 //
-//  Unless required by applicable law or agreed to in writing, software
-//  distributed under the License is distributed on an "AS IS" BASIS,
-//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-//  See the License for the specific language governing permissions and
-//  limitations under the License.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.patch.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
new file mode 100644
index 0000000..622f0cf
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Abstract permission representing a label. */
+public abstract class AbstractLabelPermission implements ChangePermissionOrLabel {
+  public enum ForUser {
+    SELF,
+    ON_BEHALF_OF
+  }
+
+  protected final ForUser forUser;
+  protected final String name;
+
+  /**
+   * Construct a reference to an abstract label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public AbstractLabelPermission(ForUser forUser, String name) {
+    this.forUser = requireNonNull(forUser, "ForUser");
+    this.name = LabelType.checkName(name);
+  }
+
+  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  public ForUser forUser() {
+    return forUser;
+  }
+
+  /** Returns name of the label, e.g. {@code "Code-Review"}. */
+  public String label() {
+    return name;
+  }
+
+  protected abstract String permissionPrefix();
+
+  protected String permissionName() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + "As";
+    }
+    return permissionPrefix();
+  }
+
+  @Override
+  public final String describeForException() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + " on behalf of " + name;
+    }
+    return permissionPrefix() + " " + name;
+  }
+
+  @Override
+  public final int hashCode() {
+    return (permissionPrefix() + name).hashCode();
+  }
+
+  @Override
+  @SuppressWarnings("EqualsGetClass")
+  public final boolean equals(Object other) {
+    if (this.getClass().isAssignableFrom(other.getClass())) {
+      AbstractLabelPermission b = (AbstractLabelPermission) other;
+      return forUser == b.forUser && name.equals(b.name);
+    }
+    return false;
+  }
+
+  @Override
+  public final String toString() {
+    return permissionName() + "[" + name + ']';
+  }
+
+  /** A {@link AbstractLabelPermission} at a specific value. */
+  public abstract static class WithValue implements ChangePermissionOrLabel {
+    private final ForUser forUser;
+    private final LabelVote label;
+
+    /**
+     * Construct a reference to an abstract label permission at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param label label name and vote.
+     */
+    public WithValue(ForUser forUser, LabelVote label) {
+      this.forUser = requireNonNull(forUser, "ForUser");
+      this.label = requireNonNull(label, "LabelVote");
+    }
+
+    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    public ForUser forUser() {
+      return forUser;
+    }
+
+    /** Returns name of the label, e.g. {@code "Code-Review"}. */
+    public String label() {
+      return label.label();
+    }
+
+    /** Returns specific value of the label, e.g. 1 or 2. */
+    public short value() {
+      return label.value();
+    }
+
+    public abstract String permissionName();
+
+    @Override
+    public final String describeForException() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + " on behalf of " + label.formatWithEquals();
+      }
+      return permissionName() + " " + label.formatWithEquals();
+    }
+
+    @Override
+    public final int hashCode() {
+      return (permissionName() + label).hashCode();
+    }
+
+    @Override
+    @SuppressWarnings("EqualsGetClass")
+    public final boolean equals(Object other) {
+      if (this.getClass().isAssignableFrom(other.getClass())) {
+        AbstractLabelPermission.WithValue b = (AbstractLabelPermission.WithValue) other;
+        return forUser == b.forUser && label.equals(b.label);
+      }
+      return false;
+    }
+
+    @Override
+    public final String toString() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + "As[" + label.format() + ']';
+      }
+      return permissionName() + "[" + label.format() + ']';
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 37c773a..6f7d761 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -92,8 +92,7 @@
 
   /** Can this user revert this change? */
   private boolean canRevert() {
-    return (refControl.canRevert())
-        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+    return refControl.canRevert() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
   /** The range of permitted values associated with a label permission. */
@@ -241,10 +240,10 @@
     private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
       if (perm instanceof ChangePermission) {
         return can((ChangePermission) perm);
-      } else if (perm instanceof LabelPermission) {
-        return can((LabelPermission) perm);
-      } else if (perm instanceof LabelPermission.WithValue) {
-        return can((LabelPermission.WithValue) perm);
+      } else if (perm instanceof AbstractLabelPermission) {
+        return can((AbstractLabelPermission) perm);
+      } else if (perm instanceof AbstractLabelPermission.WithValue) {
+        return can((AbstractLabelPermission.WithValue) perm);
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
@@ -257,7 +256,7 @@
           case ABANDON:
             return canAbandon();
           case DELETE:
-            return (getProjectControl().isAdmin() || (refControl.canDeleteChanges(isOwner())));
+            return getProjectControl().isAdmin() || refControl.canDeleteChanges(isOwner());
           case ADD_PATCH_SET:
             return canAddPatchSet();
           case EDIT_ASSIGNEE:
@@ -289,11 +288,11 @@
       throw new PermissionBackendException(perm + " unsupported");
     }
 
-    private boolean can(LabelPermission perm) {
+    private boolean can(AbstractLabelPermission perm) {
       return !label(labelPermissionName(perm)).isEmpty();
     }
 
-    private boolean can(LabelPermission.WithValue perm) {
+    private boolean can(AbstractLabelPermission.WithValue perm) {
       PermissionRange r = label(labelPermissionName(perm));
       if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
         return false;
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index 2824efd..f59ba02 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -16,5 +16,5 @@
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
 
-/** A {@link ChangePermission} or a {@link LabelPermission}. */
+/** A {@link ChangePermission} or a {@link AbstractLabelPermission}. */
 public interface ChangePermissionOrLabel extends GerritPermission {}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 66299a8..aa49852 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -124,11 +123,12 @@
     @Override
     public ForProject project(Project.NameKey project) {
       try {
-        ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
         ProjectControl control =
             PerThreadCache.getOrCompute(
                 PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
-                () -> projectControlFactory.create(user, state));
+                () ->
+                    projectControlFactory.create(
+                        user, projectCache.get(project).orElseThrow(illegalState(project))));
         return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index d2e85be..3f84dff 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,7 +31,6 @@
       // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
       factory(DefaultRefFilter.Factory.class);
-      factory(VisibleChangesCache.Factory.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 9d69d9b..89f0493 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -24,7 +24,7 @@
 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 com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser;
 import java.util.EnumSet;
 import java.util.Optional;
 import java.util.Set;
@@ -160,19 +160,29 @@
     return Optional.ofNullable(CHANGE_PERMISSIONS.inverse().get(permissionName));
   }
 
-  public static String labelPermissionName(LabelPermission labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission labelPermission) {
+    if (labelPermission instanceof LabelPermission) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission subtype");
   }
 
   // TODO(dborowitz): Can these share a common superinterface?
-  public static String labelPermissionName(LabelPermission.WithValue labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission.WithValue labelPermission) {
+    if (labelPermission instanceof LabelPermission.WithValue) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission.WithValue) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission.WithValue subtype");
   }
 
   private DefaultPermissionMappings() {}
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 10aa9cd..eebaa8f 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,19 +15,22 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toCollection;
 
 import com.google.auto.value.AutoValue;
+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.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.RefNames;
-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;
@@ -41,12 +44,14 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 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.inject.Inject;
 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.Objects;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -61,38 +66,39 @@
   }
 
   private final TagCache tagCache;
-  private final ChangeNotes.Factory changeNotesFactory;
   private final PermissionBackend permissionBackend;
   private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
   private final CurrentUser user;
   private final ProjectState projectState;
   private final PermissionBackend.ForProject permissionBackendForProject;
+  private final @Nullable SearchingChangeCacheImpl searchingChangeDataProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
-  private final VisibleChangesCache.Factory visibleChangesCacheFactory;
-
-  private VisibleChangesCache visibleChangesCache;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
-      ChangeNotes.Factory changeNotesFactory,
       PermissionBackend permissionBackend,
       RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
-      VisibleChangesCache.Factory visibleChangesCacheFactory,
+      @Nullable SearchingChangeCacheImpl searchingChangeDataProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory changeNotesFactory,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
-    this.changeNotesFactory = changeNotesFactory;
     this.permissionBackend = permissionBackend;
     this.refVisibilityControl = refVisibilityControl;
+    this.searchingChangeDataProvider = searchingChangeDataProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeNotesFactory = changeNotesFactory;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
-    this.visibleChangesCacheFactory = visibleChangesCacheFactory;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
@@ -112,9 +118,8 @@
   }
 
   /** Filters given refs and tags by visibility. */
-  Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
+  ImmutableList<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
-    visibleChangesCache = visibleChangesCacheFactory.create(projectControl, repo);
     logger.atFinest().log(
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
@@ -127,32 +132,26 @@
         "Project state %s permits read = %s",
         projectState.getProject().getState(), projectState.statePermitsRead());
 
-    // See if we can get away with a single, cheap ref evaluation.
-    if (refs.size() == 1) {
-      String refName = Iterables.getOnlyElement(refs).getName();
-      if (opts.filterMeta() && isMetadata(refName)) {
-        logger.atFinest().log("Filter out metadata ref %s", refName);
-        return ImmutableList.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 ImmutableList.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(new ArrayList<>(refs), opts);
-    List<Ref> visibleRefs = initialRefFilter.visibleRefs();
+    Supplier<ImmutableMap<Change.Id, ChangeData>> visibleChanges =
+        Suppliers.memoize(
+            () ->
+                GitVisibleChangeFilter.getVisibleChanges(
+                    searchingChangeDataProvider,
+                    changeNotesFactory,
+                    changeDataFactory,
+                    projectState.getNameKey(),
+                    permissionBackendForProject,
+                    repo,
+                    changes(refs)));
+    Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts, visibleChanges);
+    ImmutableList.Builder<Ref> visibleRefs = ImmutableList.builder();
+    visibleRefs.addAll(initialRefFilter.visibleRefs());
     if (!initialRefFilter.deferredTags().isEmpty()) {
       try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
-        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts);
+        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts, visibleChanges);
         checkState(
             allVisibleBranches.deferredTags().isEmpty(),
             "unexpected tags found when filtering refs/heads/* "
@@ -177,8 +176,9 @@
       }
     }
 
-    logger.atFinest().log("visible refs = %s", visibleRefs);
-    return visibleRefs;
+    ImmutableList<Ref> visibleRefList = visibleRefs.build();
+    logger.atFinest().log("visible refs = %s", visibleRefList);
+    return visibleRefList;
   }
 
   /**
@@ -186,25 +186,34 @@
    * 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(List<Ref> refs, RefFilterOptions opts) throws PermissionBackendException {
+  Result filterRefs(
+      List<Ref> refs,
+      RefFilterOptions opts,
+      Supplier<ImmutableMap<Change.Id, ChangeData>> visibleChanges)
+      throws PermissionBackendException {
     logger.atFinest().log("Filter refs (refs = %s)", refs);
+    if (!projectState.statePermitsRead()) {
+      return new AutoValue_DefaultRefFilter_Result(ImmutableList.of(), ImmutableList.of());
+    }
 
     // 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) {
+      if (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());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(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());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(refs), ImmutableList.of());
       }
     }
     logger.atFinest().log("Doing full ref filtering");
@@ -214,8 +223,8 @@
         permissionBackend
             .user(projectControl.getUser())
             .testOrFalse(GlobalPermission.ACCESS_DATABASE);
-    List<Ref> resultRefs = new ArrayList<>(refs.size());
-    List<Ref> deferredTags = new ArrayList<>();
+    ImmutableList.Builder<Ref> resultRefs = ImmutableList.builderWithExpectedSize(refs.size());
+    ImmutableList.Builder<Ref> deferredTags = ImmutableList.builder();
     for (Ref ref : refs) {
       String refName = ref.getName();
       Change.Id changeId;
@@ -251,9 +260,9 @@
         // most recent changes).
         if (hasAccessDatabase) {
           resultRefs.add(ref);
-        } else if (!visibleChangesCache.isVisible(changeId)) {
+        } else if (!visibleChanges.get().containsKey(changeId)) {
           logger.atFinest().log("Filter out invisible change ref %s", refName);
-        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName)) {
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName, visibleChanges.get())) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
@@ -263,7 +272,7 @@
         resultRefs.add(ref);
       }
     }
-    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
+    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs.build(), deferredTags.build());
     logger.atFinest().log("Result of ref filtering = %s", result);
     return result;
   }
@@ -291,6 +300,19 @@
     }
   }
 
+  /**
+   * Returns the number of changes contained in {@code refs}. A change has one meta ref and many
+   * patch set refs. We count over the meta refs to make sure we get the number of unique changes in
+   * the provided refs.
+   */
+  private static ImmutableSet<Change.Id> changes(Collection<Ref> refs) {
+    return refs.stream()
+        .map(Ref::getName)
+        .map(Change.Id::fromRef)
+        .filter(Objects::nonNull)
+        .collect(toImmutableSet());
+  }
+
   private List<Ref> fastHideRefsMetaConfig(List<Ref> refs) throws PermissionBackendException {
     if (!canReadRef(REFS_CONFIG)) {
       return refs.stream()
@@ -300,7 +322,8 @@
     return refs;
   }
 
-  private boolean visibleEdit(String name) throws PermissionBackendException {
+  private boolean visibleEdit(String name, ImmutableMap<Change.Id, ChangeData> visibleChanges)
+      throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
     if (id == null) {
       logger.atWarning().log("Couldn't extract change ID from edit ref %s", name);
@@ -309,23 +332,19 @@
 
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
-        && visibleChangesCache.isVisible(id)) {
+        && visibleChanges.containsKey(id)) {
       logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
 
-    if (visibleChangesCache.isVisible(id)) {
-      try {
-        // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
-        permissionBackendForProject
-            .ref(visibleChangesCache.getBranchNameKey(id).branch())
-            .check(RefPermission.READ_PRIVATE_CHANGES);
-        logger.atFinest().log("Foreign change edit ref is visible: %s", name);
-        return true;
-      } catch (AuthException e) {
-        logger.atFinest().log("Foreign change edit ref is not visible: %s", name);
-        return false;
-      }
+    if (visibleChanges.containsKey(id)) {
+      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+      BranchNameKey dest = visibleChanges.get(id).change().getDest();
+      boolean canRead =
+          permissionBackendForProject.ref(dest.branch()).test(RefPermission.READ_PRIVATE_CHANGES);
+      logger.atFinest().log(
+          "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", name);
+      return canRead;
     }
 
     logger.atFinest().log("Change %d of change edit ref %s is not visible", id.get(), name);
@@ -343,69 +362,24 @@
   }
 
   private boolean canReadRef(String ref) throws PermissionBackendException {
-    try {
-      permissionBackendForProject.ref(ref).check(RefPermission.READ);
-    } catch (AuthException e) {
-      return false;
-    }
-    return projectState.statePermitsRead();
+    return permissionBackendForProject.ref(ref).test(RefPermission.READ);
   }
 
   private boolean checkProjectPermission(
       PermissionBackend.ForProject forProject, ProjectPermission perm)
       throws PermissionBackendException {
-    try {
-      forProject.check(perm);
-    } catch (AuthException e) {
-      return false;
-    }
-    return true;
-  }
-
-  /**
-   * 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);
-    if (cId == null) {
-      // The ref is not a valid change ref. Treat it as non-visible since it's not representing a
-      // change.
-      logger.atWarning().log("invalid change ref %s is not visible", refName);
-      return false;
-    }
-    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;
-    }
+    return forProject.test(perm);
   }
 
   @AutoValue
   abstract static class Result {
     /** Subset of the refs passed into the computation that is visible to the user. */
-    abstract List<Ref> visibleRefs();
+    abstract ImmutableList<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();
+    abstract ImmutableList<Ref> deferredTags();
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
new file mode 100644
index 0000000..0e5ff48
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class can tell efficiently if changes are visible to a user. It is intended to be used when
+ * serving Git traffic on the Git wire protocol and in similar use cases when we need to know
+ * efficiently if a (potentially large number) of changes are visible to a user.
+ *
+ * <p>The efficiency of this class comes from heuristic optimization:
+ *
+ * <ul>
+ *   <li>For a low number of expected checks, we check visibility one-by-one.
+ *   <li>For a high number of expected checks and settings where the change index is available, we
+ *       load the N most recent changes from the index and filter them by visibility. This is fast,
+ *       but comes with the caveat that older changes are pretended to be invisible.
+ *   <li>For a high number of expected checks and settings where the change index is unavailable, we
+ *       scan the repo and determine visibility one-by-one. This is *very* expensive.
+ * </ul>
+ *
+ * <p>Changes that fail to load are pretended to be invisible. This is important on the Git paths as
+ * we don't want to advertise change refs where we were unable to check the visibility (e.g. due to
+ * data corruption on that change). At the same time, the overall operation should succeed as
+ * otherwise a single broken change would break Git operations for an entire repo.
+ */
+public class GitVisibleChangeFilter {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int CHANGE_LIMIT_FOR_DIRECT_FILTERING = 5;
+
+  private GitVisibleChangeFilter() {}
+
+  /** Returns a map of all visible changes. Might pretend old changes are invisible. */
+  static ImmutableMap<Change.Id, ChangeData> getVisibleChanges(
+      @Nullable SearchingChangeCacheImpl searchingChangeCache,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      Project.NameKey projectName,
+      PermissionBackend.ForProject forProject,
+      Repository repository,
+      ImmutableSet<Change.Id> changes) {
+    Stream<ChangeData> changeDatas;
+    if (changes.size() < CHANGE_LIMIT_FOR_DIRECT_FILTERING) {
+      logger.atFine().log("Loading changes one by one for project %s", projectName);
+      changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName);
+    } else if (searchingChangeCache != null) {
+      logger.atFine().log("Loading changes from SearchingChangeCache for project %s", projectName);
+      changeDatas = searchingChangeCache.getChangeData(projectName);
+    } else {
+      logger.atFine().log("Loading changes from all refs for project %s", projectName);
+      changeDatas =
+          scanRepoForChangeDatas(changeNotesFactory, changeDataFactory, repository, projectName);
+    }
+    HashMap<Change.Id, ChangeData> result = new HashMap<>();
+    changeDatas
+        .filter(cd -> changes.contains(cd.getId()))
+        .filter(
+            cd -> {
+              try {
+                return forProject.change(cd).test(ChangePermission.READ);
+              } catch (PermissionBackendException e) {
+                throw new StorageException(e);
+              }
+            })
+        .forEach(
+            cd -> {
+              if (result.containsKey(cd.getId())) {
+                logger.atWarning().log(
+                    "Duplicate change datas for the repo %s: [%s, %s]",
+                    projectName, cd, result.get(cd.getId()));
+              }
+              result.put(cd.getId(), cd);
+            });
+    return ImmutableMap.copyOf(result);
+  }
+
+  /** Get a stream of changes by loading them individually. */
+  private static Stream<ChangeData> loadChangeDatasOneByOne(
+      Set<Change.Id> ids, ChangeData.Factory changeDataFactory, Project.NameKey projectName) {
+    return ids.stream()
+        .map(
+            id -> {
+              try {
+                ChangeData cd = changeDataFactory.create(projectName, id);
+                cd.notes(); // Make sure notes are available. This will trigger loading notes and
+                // throw an exception in case the change is corrupt and can't be loaded. It will
+                // then be omitted from the result.
+                return cd;
+              } catch (Exception e) {
+                // We drop changes that we can't load. The repositories contain 'dead' change refs
+                // and we want to overall operation to continue.
+                logger.atFinest().withCause(e).log("Can't load Change notes for %s", id);
+                return null;
+              }
+            })
+        .filter(Objects::nonNull);
+  }
+
+  /** Get a stream of all changes by scanning the repo. This is extremely slow. */
+  private static Stream<ChangeData> scanRepoForChangeDatas(
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      Repository repository,
+      Project.NameKey projectName) {
+    Stream<ChangeData> cds;
+    try {
+      cds =
+          changeNotesFactory
+              .scan(repository, projectName)
+              .map(
+                  notesResult -> {
+                    if (!notesResult.error().isPresent()) {
+                      return changeDataFactory.create(notesResult.notes());
+                    }
+                    logger.atWarning().withCause(notesResult.error().get()).log(
+                        "Unable to load ChangeNotes for %s", notesResult.id());
+                    return null;
+                  })
+              .filter(Objects::nonNull);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+    return cds;
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index c266caa..4652364 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -14,24 +14,14 @@
 
 package com.google.gerrit.server.permissions;
 
-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 static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
 
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.util.LabelVote;
 
 /** Permission representing a label. */
-public class LabelPermission implements ChangePermissionOrLabel {
-  public enum ForUser {
-    SELF,
-    ON_BEHALF_OF;
-  }
-
-  private final ForUser forUser;
-  private final String name;
-
+public class LabelPermission extends AbstractLabelPermission {
   /**
    * Construct a reference to a label permission.
    *
@@ -67,55 +57,16 @@
    * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
    */
   public LabelPermission(ForUser forUser, String name) {
-    this.forUser = requireNonNull(forUser, "ForUser");
-    this.name = LabelType.checkName(name);
-  }
-
-  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-  public ForUser forUser() {
-    return forUser;
-  }
-
-  /** Returns name of the label, e.g. {@code "Code-Review"}. */
-  public String label() {
-    return name;
+    super(forUser, name);
   }
 
   @Override
-  public String describeForException() {
-    if (forUser == ON_BEHALF_OF) {
-      return "label on behalf of " + name;
-    }
-    return "label " + name;
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other instanceof LabelPermission) {
-      LabelPermission b = (LabelPermission) other;
-      return forUser == b.forUser && name.equals(b.name);
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    if (forUser == ON_BEHALF_OF) {
-      return "LabelAs[" + name + ']';
-    }
-    return "Label[" + name + ']';
+  public String permissionPrefix() {
+    return "label";
   }
 
   /** A {@link LabelPermission} at a specific value. */
-  public static class WithValue implements ChangePermissionOrLabel {
-    private final ForUser forUser;
-    private final LabelVote label;
-
+  public static class WithValue extends AbstractLabelPermission.WithValue {
     /**
      * Construct a reference to a label at a specific value.
      *
@@ -195,53 +146,12 @@
      * @param label label name and vote.
      */
     public WithValue(ForUser forUser, LabelVote label) {
-      this.forUser = requireNonNull(forUser, "ForUser");
-      this.label = requireNonNull(label, "LabelVote");
-    }
-
-    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-    public ForUser forUser() {
-      return forUser;
-    }
-
-    /** Returns name of the label, e.g. {@code "Code-Review"}. */
-    public String label() {
-      return label.label();
-    }
-
-    /** Returns specific value of the label, e.g. 1 or 2. */
-    public short value() {
-      return label.value();
+      super(forUser, label);
     }
 
     @Override
-    public String describeForException() {
-      if (forUser == ON_BEHALF_OF) {
-        return "label on behalf of " + label.formatWithEquals();
-      }
-      return "label " + label.formatWithEquals();
-    }
-
-    @Override
-    public int hashCode() {
-      return label.hashCode();
-    }
-
-    @Override
-    public boolean equals(Object other) {
-      if (other instanceof WithValue) {
-        WithValue b = (WithValue) other;
-        return forUser == b.forUser && label.equals(b.label);
-      }
-      return false;
-    }
-
-    @Override
-    public String toString() {
-      if (forUser == ON_BEHALF_OF) {
-        return "LabelAs[" + label.format() + ']';
-      }
-      return "Label[" + label.format() + ']';
+    public String permissionName() {
+      return "label";
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
new file mode 100644
index 0000000..2553601
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.server.permissions.AbstractLabelPermission.ForUser.SELF;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Permission representing a label removal. */
+public class LabelRemovalPermission extends AbstractLabelPermission {
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param type type description of the label.
+   */
+  public LabelRemovalPermission(LabelType type) {
+    this(type.getName());
+  }
+
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelRemovalPermission(String name) {
+    super(SELF, name);
+  }
+
+  @Override
+  public String permissionPrefix() {
+    return "removeLabel";
+  }
+
+  /** A {@link LabelRemovalPermission} at a specific value. */
+  public static class WithValue extends AbstractLabelPermission.WithValue {
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, LabelValue value) {
+      this(type.getName(), value.getValue());
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, short value) {
+      this(type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(String name, short value) {
+      this(LabelVote.create(name, value));
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param label label name and vote.
+     */
+    public WithValue(LabelVote label) {
+      super(SELF, label);
+    }
+
+    @Override
+    public String permissionName() {
+      return "removeLabel";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 27c6793..eb5e053 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -173,7 +173,13 @@
       return ref(notes.getChange().getDest()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(GlobalOrPluginPermission)}.
+     */
     public abstract void check(GlobalOrPluginPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -240,10 +246,9 @@
       Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
       for (Project.NameKey project : projects) {
         try {
-          project(project).check(perm);
-          allowed.add(project);
-        } catch (AuthException e) {
-          // Do not include this project in allowed.
+          if (project(project).test(perm)) {
+            allowed.add(project);
+          }
         } catch (PermissionBackendException e) {
           if (e.getCause() instanceof RepositoryNotFoundException) {
             logger.atWarning().withCause(e).log(
@@ -280,7 +285,13 @@
       return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(CoreOrPluginProjectPermission)}.
+     */
     public abstract void check(CoreOrPluginProjectPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -368,7 +379,13 @@
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeNotes notes);
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(RefPermission)}.
+     */
     public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
 
     /** Filter {@code permSet} to permissions scoped user might be able to perform. */
@@ -406,7 +423,13 @@
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(ChangePermissionOrLabel)}.
+     */
     public abstract void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException;
 
@@ -451,6 +474,18 @@
     }
 
     /**
+     * Test which values of a label the user may be able to remove.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testRemoval(LabelType label)
+        throws PermissionBackendException {
+      return test(removalValuesOf(requireNonNull(label, "LabelType")));
+    }
+
+    /**
      * Test which values of a group of labels the user may be able to set.
      *
      * @param types definition of the labels to test values of.
@@ -463,10 +498,29 @@
       return test(types.stream().flatMap(t -> valuesOf(t).stream()).collect(toSet()));
     }
 
+    /**
+     * Test which values of a group of labels the user may be able to remove.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testLabelRemovals(Collection<LabelType> types)
+        throws PermissionBackendException {
+      requireNonNull(types, "LabelType");
+      return test(types.stream().flatMap(t -> removalValuesOf(t).stream()).collect(toSet()));
+    }
+
     private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
       return label.getValues().stream()
           .map(v -> new LabelPermission.WithValue(label, v))
           .collect(toSet());
     }
+
+    private static Set<LabelRemovalPermission.WithValue> removalValuesOf(LabelType label) {
+      return label.getValues().stream()
+          .map(v -> new LabelRemovalPermission.WithValue(label, v))
+          .collect(toSet());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 1203049..c235012 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -165,9 +166,8 @@
 
   boolean isAdmin() {
     try {
-      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
+      return permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (PermissionBackendException e) {
       return false;
     }
   }
@@ -270,6 +270,7 @@
     return false;
   }
 
+  @Nullable
   private Boolean canPerform(String permissionName, AccessSection section, Permission permission) {
     for (PermissionRule rule : permission.getRules()) {
       if (rule.isBlock() || rule.isDeny() || !match(rule)) {
@@ -347,7 +348,6 @@
   }
 
   private class ForProjectImpl extends ForProject {
-    private DefaultRefFilter refFilter;
     private String resourcePath;
 
     @Override
@@ -416,10 +416,7 @@
     @Override
     public Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
-      if (refFilter == null) {
-        refFilter = refFilterFactory.create(ProjectControl.this);
-      }
-      return refFilter.filter(refs, repo, opts);
+      return refFilterFactory.create(ProjectControl.this).filter(refs, repo, opts);
     }
 
     private boolean can(CoreOrPluginProjectPermission perm) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 6b51335..ba292e6 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
@@ -177,6 +178,7 @@
   }
 
   /** The range of permitted values associated with a label permission. */
+  @Nullable
   PermissionRange getRange(String permission, boolean isChangeOwner) {
     if (Permission.hasRange(permission)) {
       return toRange(permission, isChangeOwner);
@@ -271,13 +273,7 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return
-        // We allow owner to delete refs even if they have no force-push rights. We forbid
-        // it if force push is blocked, though. See commit 40bd5741026863c99bea13eb5384bd27855c5e1b
-        (isOwner() && !isBlocked(Permission.PUSH, false, true))
-            || canPushWithForce()
-            || canPerform(Permission.DELETE)
-            || projectControl.isAdmin();
+        return canPushWithForce() || canPerform(Permission.DELETE) || projectControl.isAdmin();
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index cc6387b..c2d1139 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -170,17 +169,14 @@
       return true;
     }
 
-    try {
-      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
-      projectControl
-          .asForProject()
-          .ref(cd.change().getDest().branch())
-          .check(RefPermission.READ_PRIVATE_CHANGES);
-      logger.atFinest().log("Foreign change edit ref is visible: %s", refName);
-      return true;
-    } catch (AuthException e) {
-      logger.atFinest().log("Foreign change edit ref is not visible: %s", refName);
-      return false;
-    }
+    // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+    boolean canRead =
+        projectControl
+            .asForProject()
+            .ref(cd.change().getDest().branch())
+            .test(RefPermission.READ_PRIVATE_CHANGES);
+    logger.atFinest().log(
+        "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", refName);
+    return canRead;
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index e64f8b6..552d8ee 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -125,14 +125,15 @@
   abstract static class EntryKey {
     public abstract String ref();
 
-    public abstract List<String> patterns();
+    public abstract ImmutableList<String> patterns();
 
     static EntryKey create(String refName, List<AccessSection> sections) {
-      List<String> patterns = new ArrayList<>(sections.size());
+      ImmutableList.Builder<String> patterns =
+          ImmutableList.builderWithExpectedSize(sections.size());
       for (AccessSection s : sections) {
         patterns.add(s.getName());
       }
-      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns));
+      return new AutoValue_SectionSortCache_EntryKey(refName, patterns.build());
     }
 
     @Memoized
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
deleted file mode 100644
index 2e47576..0000000
--- a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
+++ /dev/null
@@ -1,169 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.collect.ImmutableList.toImmutableList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * Gets all of the visible by current user changes in the repository that are available in the
- * change index and cache.
- */
-class VisibleChangesCache {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  interface Factory {
-    VisibleChangesCache create(ProjectControl projectControl, Repository repository);
-  }
-
-  @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final ProjectState projectState;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final PermissionBackend.ForProject permissionBackendForProject;
-
-  private final Repository repository;
-  private Map<Change.Id, BranchNameKey> visibleChanges;
-
-  @Inject
-  VisibleChangesCache(
-      @Nullable SearchingChangeCacheImpl changeCache,
-      PermissionBackend permissionBackend,
-      ChangeNotes.Factory changeNotesFactory,
-      @Assisted ProjectControl projectControl,
-      @Assisted Repository repository) {
-    this.changeCache = changeCache;
-    this.projectState = projectControl.getProjectState();
-    this.permissionBackendForProject =
-        permissionBackend.user(projectControl.getUser()).project(projectState.getNameKey());
-    this.changeNotesFactory = changeNotesFactory;
-    this.repository = repository;
-  }
-
-  /**
-   * Returns {@code true} if the {@code changeId} in repository {@code repo} is visible to the user,
-   * by looking at the cached visible changes.
-   */
-  public boolean isVisible(Change.Id changeId) throws PermissionBackendException {
-    cachedVisibleChanges();
-    return visibleChanges.containsKey(changeId);
-  }
-
-  /**
-   * Returns the visible changes in the repository {@code repo}. If not cached, computes the visible
-   * changes and caches them.
-   */
-  public Map<Change.Id, BranchNameKey> cachedVisibleChanges() throws PermissionBackendException {
-    if (visibleChanges == null) {
-      if (changeCache == null) {
-        visibleChangesByScan();
-      } else {
-        visibleChangesBySearch();
-      }
-      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
-    }
-    return visibleChanges;
-  }
-
-  /**
-   * Returns the {@code BranchNameKey} for {@code changeId}. If not cached, computes *all* visible
-   * changes and caches them before returning this specific change. If not visible or not found,
-   * returns {@code null}.
-   */
-  @Nullable
-  public BranchNameKey getBranchNameKey(Change.Id changeId) throws PermissionBackendException {
-    return cachedVisibleChanges().get(changeId);
-  }
-
-  private void visibleChangesBySearch() throws PermissionBackendException {
-    visibleChanges = new HashMap<>();
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      for (ChangeData cd : changeCache.getChangeData(project)) {
-        if (!projectState.statePermitsRead()) {
-          continue;
-        }
-        try {
-          permissionBackendForProject.change(cd).check(ChangePermission.READ);
-          visibleChanges.put(cd.getId(), cd.change().getDest());
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", project);
-    }
-  }
-
-  private void visibleChangesByScan() throws PermissionBackendException {
-    visibleChanges = new HashMap<>();
-    Project.NameKey p = projectState.getNameKey();
-    ImmutableList<ChangeNotesResult> changes;
-    try {
-      changes = changeNotesFactory.scan(repository, p).collect(toImmutableList());
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", p);
-      return;
-    }
-
-    for (ChangeNotesResult notesResult : changes) {
-      ChangeNotes notes = toNotes(notesResult);
-      if (notes != null) {
-        visibleChanges.put(notes.getChangeId(), notes.getChange().getDest());
-      }
-    }
-  }
-
-  @Nullable
-  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 {
-      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
-      return r.notes();
-    } catch (AuthException e) {
-      // Skip.
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
index fb50cd5..cd61429 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.inject.Inject;
 import java.util.Iterator;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 
 /**
  * Context to invoke extensions from a {@link DynamicMap}.
@@ -135,7 +135,7 @@
    * @return sorted list of the plugins that have registered implementations for this extension
    *     point
    */
-  public SortedSet<String> plugins() {
+  public NavigableSet<String> plugins() {
     return dynamicMap.plugins();
   }
 
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 82f97c9..c00a69d 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -29,9 +29,10 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
@@ -104,8 +105,8 @@
   }
 
   private static String tempNameFor(String name) {
-    SimpleDateFormat fmt = new SimpleDateFormat("yyMMdd_HHmm");
-    return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(new Date()) + "_";
+    DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyMMdd_HHmm").withZone(ZoneId.of("UTC"));
+    return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(Instant.now()) + "_";
   }
 
   public static Path storeInTemp(String pluginName, InputStream in, SitePaths sitePaths)
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index 0f87135..122e3f4 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.server.plugins;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.Iterables.transform;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 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.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
@@ -31,7 +32,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -42,6 +42,7 @@
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 import org.eclipse.jgit.util.IO;
 import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.Attribute;
@@ -78,9 +79,7 @@
       classObjToClassDescr.put(annotation, descriptor);
     }
 
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
+    for (JarEntry entry : entriesOf(jarFile)) {
       if (skip(entry)) {
         continue;
       }
@@ -137,9 +136,7 @@
     String name = superClass.replace('.', '/');
 
     List<String> classes = new ArrayList<>();
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
+    for (JarEntry entry : entriesOf(jarFile)) {
       if (skip(entry)) {
         continue;
       }
@@ -216,6 +213,7 @@
       this.superName = superName;
     }
 
+    @Nullable
     @Override
     public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
       if (!visible) {
@@ -294,10 +292,9 @@
   }
 
   @Override
-  public Enumeration<PluginEntry> entries() {
-    return Collections.enumeration(
-        Lists.transform(
-            Collections.list(jarFile.entries()),
+  public Stream<PluginEntry> entries() {
+    return jarFile.stream()
+        .map(
             jarEntry -> {
               try {
                 return resourceOf(jarEntry);
@@ -305,7 +302,7 @@
                 throw new IllegalArgumentException(
                     "Cannot convert jar entry " + jarEntry + " to a resource", e);
               }
-            }));
+            });
   }
 
   @Override
@@ -333,4 +330,8 @@
     }
     return Maps.transformEntries(attributes, (key, value) -> (String) value);
   }
+
+  private static Iterable<JarEntry> entriesOf(JarFile jarFile) {
+    return jarFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/java/com/google/gerrit/server/plugins/PluginContentScanner.java
index b19d6de..12c06f3 100644
--- a/java/com/google/gerrit/server/plugins/PluginContentScanner.java
+++ b/java/com/google/gerrit/server/plugins/PluginContentScanner.java
@@ -19,10 +19,10 @@
 import java.lang.annotation.Annotation;
 import java.nio.file.NoSuchFileException;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.Map;
 import java.util.Optional;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 
 /**
  * Scans the pl