Merge branch 'stable-3.5' into stable-3.6

* stable-3.5:
  Set version to 3.5.5-SNAPSHOT
  Set version to 3.5.4
  CopyApprovalsIT: remove unnecessary comments
  Log progress of the copy-approvals program
  copy-approvals: do not lock loose refs when executing BatchRefUpdate
  copy-approvals: multi-threaded, slice based
  Update git submodules
  Set version to 3.4.9-SNAPSHOT
  Set version to 3.4.8
  Fix bulk loading of entries with PassthroughLoadingCache
  Update git submodules
  Set version to 3.4.8-SNAPSHOT
  Set version to 3.4.7
  Introduce a PassthroughLoadingCache for disabled caches
  Export commons:lang3 dependency to plugin API
  copy-approvals: continue when there are corrupt meta-refs in a project
  copy-approvals: don't stop when it fails on one project
  Fix CopyApprovalsIT.multipleProjects: make sure to use the secondProject

Release-Notes: skip
Change-Id: Ie771563ec237a96f591c2e20772d07a9d06c502d
diff --git a/.bazelrc b/.bazelrc
index b4eafb1..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.
@@ -14,7 +37,6 @@
 build --announce_rc
 
 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 af8c8ec..0062ac9 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-4.2.2
+5.0.0
diff --git a/.gitignore b/.gitignore
index 95f94ba..8edc10e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 # Keep following lines sorted according to `LC_COLLATE=C sort`
+*.code-workspace
 *.eml
 *.iml
 *.log
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/cmd-copy-approvals.txt b/Documentation/cmd-copy-approvals.txt
deleted file mode 100644
index ba5344f..0000000
--- a/Documentation/cmd-copy-approvals.txt
+++ /dev/null
@@ -1,60 +0,0 @@
-= gerrit copy-approvals
-
-== NAME
-gerrit copy-approvals - Copy all inferred approvals labels to the latest patch-set.
-
-== SYNOPSIS
-[verse]
---
-_ssh_ -p <port> <host> _gerrit copy-approvals_
-  [--verbose | -v]
-  [PROJECT]...
---
-
-== DESCRIPTION
-Gerrit has historically computed votes using an inference algorithm that
-was cumulating them from all the patch-sets. That was not efficient since
-it had to take into account copied votes from very old patchsets.
-E.g, votes sometimes need to be copied from ps1 to ps10.
-
-Gerrit copy the approvals from the inferred votes to the latest patch-sets
-once a change receives a new label update.
-
-The copy-approval command scans all the changes of a project and looks for
-all votes that have not been copied yet, calculate the inferred score and
-apply that as copied label to the latest patch-set.
-
-NOTE: The label copied as part of this process receives the grant date of
-the timestamp of the copy-approval command execution, not the one associated
-with the inferred vote.
-
-== OPTIONS
-
---verbose::
--v::
-	Display projects/changes impacted by the label copy operation.
-
-== ACCESS
-Only the user with MAINTAIN_SERVER permissions can run this command.
-
-== SCRIPTING
-This command is intended to be used in scripts.
-
-== EXAMPLES
-
-Copy all inferred labels on the project 'foo'
-----
-$ ssh -p 29418 review.example.com gerrit copy-approvals foo
-----
-
-Copy all inferred labels on all projects
-----
-$ ssh -p 29418 review.example.com gerrit copy-approvals
-----
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
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-gerrit.txt b/Documentation/config-gerrit.txt
index 2685d3f..b1949e6 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -962,12 +962,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
@@ -1061,12 +1055,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"`::
 +
@@ -1213,6 +1201,14 @@
 Result of checking if one change or commit is a pure/clean revert of
 another.
 
+cache `"soy_sauce_compiled_templates"`::
++
+Caches compiled soy templates. Stores at most only one key-value pair with
+a constant key value and the value is a compiled SoySauce templates. The value
+is reloaded automatically every few seconds if there are reads from the cache.
+If cache is not used for 1 minute, the item is removed (i.e. emails can be send
+with templates which are max 1 minute old).
+
 cache `"sshkeys"`::
 +
 Caches unpacked versions of user SSH keys, so the internal SSH daemon
@@ -2056,6 +2052,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
 
@@ -4969,16 +4973,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
 
@@ -5199,22 +5193,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::
 +
@@ -5232,6 +5231,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.
 
@@ -5260,6 +5264,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`
@@ -5270,12 +5277,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::
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 5889c75..3fa84b1 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -6,7 +6,10 @@
 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.
 
 [[label_Code-Review]]
 == Label: Code-Review
@@ -18,7 +21,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 +31,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 +64,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 +100,7 @@
       value = -1 Fails
       value = 0 No score
       value = +1 Verified
-      copyAllScoresIfNoCodeChange = true
+      copyCondition = changekind:NO_CODE_CHANGE
 ----
 
 The range of values is:
@@ -176,6 +179,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 +208,12 @@
 
 
 [[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**, favour
+using link:config-submit-requirements.html[submit requirements] instead,
+except if it's needed to set the value to `PatchSetLock`.
 
 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.
@@ -265,6 +278,9 @@
 [[label_copyAnyScore]]
 === `label.Label-Name.copyAnyScore`
 
+*DEPRECATED: use `is:ANY` predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 If true, any score for the label is copied forward when a new patch
 set is uploaded. Defaults to false.
 
@@ -284,29 +300,37 @@
 
 Matches if the diff between two patch sets was of a certain change kind.
 
+`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`.
+
+`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
+`changekind:MERGE_FIRST_PARENT_UPDATE` (only if the change is for a
+merge commit).
+
 ==== is:{MIN,MAX,ANY}
 
 Matches votes that are equal to the minimal or maximal voting range. Or any votes.
 
-==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
+==== 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 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).
+Matches all votes if the new patch set was uploaded by a member of
+link:#group-id[\{group-id\}].
 
 ==== has:unchanged-files
 
@@ -314,6 +338,29 @@
 
 Only 'unchanged-files' is supported for 'has'.
 
+[[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
 
 ----
@@ -323,6 +370,9 @@
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
+*DEPRECATED: use `is:MIN` predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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.
@@ -330,6 +380,9 @@
 [[label_copyMaxScore]]
 === `label.Label-Name.copyMaxScore`
 
+*DEPRECATED: use `is:MAX` predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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
@@ -338,6 +391,9 @@
 [[label_copyAllScoresIfListOfFilesDidNotChange]]
 === `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
 
+*DEPRECATED: use `is:ANY AND has:unchanged-files` predicates in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 This policy is useful if you don't want to trigger CI or human
 verification again if the list of files didn't change.
 
@@ -354,6 +410,9 @@
 [[label_copyAllScoresOnMergeFirstParentUpdate]]
 === `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
 
+*DEPRECATED: use `is:ANY AND changekind:MERGE_FIRST_PARENT_UPDATE` predicates
+in link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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
@@ -371,6 +430,9 @@
 [[label_copyAllScoresOnTrivialRebase]]
 === `label.Label-Name.copyAllScoresOnTrivialRebase`
 
+*DEPRECATED: use `is:ANY AND changekind:TRIVIAL_REBASE` predicates
+in link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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
@@ -388,6 +450,9 @@
 [[label_copyAllScoresIfNoCodeChange]]
 === `label.Label-Name.copyAllScoresIfNoCodeChange`
 
+*DEPRECATED: use `is:ANY AND changekind:NO_CODE_CHANGE` predicates in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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
@@ -402,6 +467,9 @@
 [[label_copyAllScoresIfNoChange]]
 === `label.Label-Name.copyAllScoresIfNoChange`
 
+*DEPRECATED: use `is:ANY AND changekind:NO_CHANGE` predicates in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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
@@ -415,6 +483,9 @@
 [[label_copyValue]]
 === `label.Label-Name.copyValue`
 
+*DEPRECATED: use `is:<value>` predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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.
@@ -457,7 +528,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 +536,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 029877b..8bd5dc7 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -226,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..9fd5b1b 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -142,7 +142,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`
@@ -481,6 +481,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 +532,7 @@
 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
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
new file mode 100644
index 0000000..2686f39
--- /dev/null
+++ b/Documentation/config-submit-requirements.txt
@@ -0,0 +1,308 @@
+= 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.
+
+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_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
+----
+
+
+[[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 e6a118e..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,16 +48,6 @@
 
 `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 :release
-```
-
 [[java-11]]
 ==== Java 11 support
 
@@ -67,53 +57,21 @@
   $ 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].
 
@@ -324,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:
 
 ----
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..8e5463d 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,13 @@
 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`
+
 ==== 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..dce5eb0 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.
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 eb94ef7..ca72f8b 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
@@ -2770,6 +2804,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/index.txt b/Documentation/index.txt
index 782a6a9..9afd6e3 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -43,6 +43,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..ff37ffc 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -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
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 3f23385..64afd5e 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -662,6 +662,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.
+* 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.
 
 [[ignore]]
 == Ignoring Or Marking Changes As 'Reviewed'
@@ -807,16 +811,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.
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ab79c8f..5b7e073 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1120,6 +1120,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
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 685e73b..e5966de 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.
 
@@ -48,10 +48,10 @@
 * commons:codec
 * commons:compress
 * commons:dbcp
-* commons:lang
 * commons:lang3
 * commons:net
 * commons:pool
+* commons:text
 * commons:validator
 * dropwizard:dropwizard-core
 * errorprone:annotations
@@ -2375,43 +2375,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
 
@@ -4060,6 +4023,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
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 49ac84c..9df4b04 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -116,7 +116,7 @@
 * `receivecommits/ps_revision_missing`: errors due to patch set revision missing
 * `receivecommits/push_count`: number of pushes
 ** `kind`:
-   The push kind (direct vs. magic).
+   The push kind (magic, direct or direct_submit).
 ** `project`:
    The name of the project for which the push is done.
 ** `type`:
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 4e93da1..8786cc4 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -24,8 +24,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..b8a30ee 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,32 +91,26 @@
 [[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.
+
+Just add code like this to your JavaScript plugin:
 
 ``` 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');
-});
+    const styleEl = document.createElement('style');
+    styleEl.innerHTML = `
+        html {
+          --header-background-color: #c3d9ff;
+        }
+        html.darkTheme {
+          --header-background-color: #c3d9ff90;
+        }
+    `;
+    document.head.appendChild(styleEl);
 ```
 
 [[high-level-api-concepts]]
@@ -146,10 +136,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)`
 
@@ -169,7 +155,8 @@
 === registerStyleModule
 `plugin.registerStyleModule(endpointName, moduleName)`
 
-See link:#low-level-style[above].
+This API is deprecated and will be removed either in version 3.6 or 3.7,
+see link:#low-level-style[above] for an alternative.
 
 === on
 Register a JavaScript callback to be invoked when events occur within
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index c16d0d4..f2a72f1 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -228,3 +228,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 cf6560b..586f685 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -61,7 +61,7 @@
 	Run init before starting the daemon. This will create a new site or
 	upgrade an existing site.
 
---s::
+-s::
 	Start link:dev-inspector.html[Gerrit Inspector] on the console, a
 	built-in interactive inspection environment to assist debugging and
 	troubleshooting of Gerrit code.
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-changes.txt b/Documentation/rest-api-changes.txt
index da3018b..8f766ea 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -224,19 +224,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]]
@@ -795,8 +794,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"
@@ -1857,7 +1856,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 +1884,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 +1925,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 +2022,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"
@@ -3973,8 +3973,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"
@@ -4581,60 +4581,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 +5030,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 +5173,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 +5257,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.
@@ -6520,7 +6469,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.
@@ -6798,28 +6754,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 +6783,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 +6791,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 +7068,9 @@
 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.
 |=============================
 
 [[description-input]]
@@ -7155,7 +7122,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
@@ -7424,11 +7395,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 +7406,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 +7426,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.
@@ -7688,22 +7651,30 @@
 === 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.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -8263,21 +8234,24 @@
 The `SubmitRequirementExpressionInfo` describes the result of evaluating a
 single submit requirement expression, for example `label:code-review=+2`.
 
-[options="header",cols="1,6"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name      |Description
-|`expression`|
+|Field Name      ||Description
+|`expression`|optional|
 The submit requirement expression as a string, for example
 `branch:refs/heads/foo and label:verified=+1`.
-|`fulfilled`|
+|`fulfilled`||
 True if the submit requirement is fulfilled for the change.
-|`passing_atoms`|
+|`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
 fulfilled for the change.
-|`failing_atoms`|
+|`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 +8294,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
@@ -8329,13 +8304,18 @@
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the applicability expression. Not set if the
 submit requirement did not define an applicability expression.
-|`submittability_expression_result`||
+Note that fields `expression`, `passing_atoms` and `failing_atoms` are always
+omitted for the `applicability_expression_result`.
+|`submittability_expression_result`|optional|
 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 expression is not evaluated and
+the field is not set.
 |`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 or
+if it does not apply.
 |===========================
 
 [[submitted-together-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index bd93b8b..86d7f58 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -2008,6 +2008,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 82b6553..413923f 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3072,8 +3072,8 @@
       "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"
       },
@@ -3119,8 +3119,8 @@
       "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"
       },
@@ -3137,8 +3137,8 @@
       "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"
       },
@@ -3182,8 +3182,8 @@
     "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"
     },
@@ -3219,8 +3219,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"
     }
@@ -3243,8 +3243,8 @@
     "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"
     },
@@ -3295,8 +3295,8 @@
     "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"
     },
@@ -3377,8 +3377,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"
       }
@@ -3547,14 +3547,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]]
@@ -3986,6 +3994,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`      ||
@@ -4006,30 +4016,32 @@
 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.
+*DEPRECATED* 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.
+*DEPRECATED* 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.
+*DEPRECATED* 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[
+*DEPRECATED* 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[
+*DEPRECATED* 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[
+*DEPRECATED* 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[
+*DEPRECATED* 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[
+*DEPRECATED* 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.
@@ -4059,6 +4071,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`.
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 1f67fc7..512f784 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?
@@ -133,24 +131,6 @@
 Gerrit-Attention: Marian Harbach <mharbach@google.com>
 ----
 
-=== Assignee
-
-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.
-
-If you don't expect action from reviewers, then consider adding them to CC
-instead.
-
-The "Assignee" feature can be turned on/off with the
-link:config-gerrit.html#change.enableAssignee[enableAssignee] config option.
-
 === Bold Changes / Mark Reviewed
 
 Before the attention set feature, changes were bolded in the dashboard when
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 128bae6..20ad07c 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -11,9 +11,9 @@
 
 Those are the available recipient types:
 
-* `to`: The standard To field is used; addresses are visible to all.
-* `cc`: The standard CC field is used; addresses are visible to all.
-* `bcc`: SMTP RCPT TO is used to hide the address.
+* `TO`: The standard To field is used; addresses are visible to all.
+* `CC`: The standard CC field is used; addresses are visible to all.
+* `BCC`: SMTP RCPT TO is used to hide the address.
 
 [[user]]
 == User Level Settings
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index f07a504..55fb0c7 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -49,6 +49,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 +268,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 +289,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 +338,12 @@
 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.
 
 [[comment]]
 comment:'TEXT'::
@@ -408,6 +429,12 @@
 current patch set. 'FOOTER' can be specified verbatim ('<key>: <value>', must
 be quoted) or as '<key>=<value>'. The matching is done case-insensitive.
 
+[[hasfooter-operator]]
+hasfooter:'FOOTERNAME'::
++
+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.
+
 [[star]]
 star:'LABEL'::
 +
@@ -507,6 +534,7 @@
 +
 Same as <<status,status:'STATE'>>.
 
+[[is-submittable]]
 is:submittable::
 +
 True if the change is submittable according to the submit rules for
@@ -518,8 +546,6 @@
 use the
 link:rest-api-changes.html#get-revision-actions[Get Revision Actions]
 API.
-+
-Equivalent to <<submittable,submittable:ok>>.
 
 [[mergeable]]
 is:mergeable::
@@ -565,6 +591,11 @@
 cherry-picked locally using the git cherry-pick command and then
 pushed to Gerrit.
 
+[[pure-revert]]
+is:pure-revert::
++
+True if the change is a pure revert.
+
 [[status]]
 status:open, status:pending, status:new::
 +
@@ -634,16 +665,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'::
 +
@@ -651,8 +672,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'::
 +
@@ -772,12 +792,41 @@
 +
 Matches changes with label voted with any score.
 
-`label:Non-Author-Code-Review=need`::
+`label:Code-Review=+1,count=2`::
++
+Matches changes with exactly two +1 votes to the code-review label. The {MAX,
+MIN, ANY} votes can also be used, for example `label:Code-Review=MAX,count=2` is
+equivalent to `label:Code-Review=2,count=2` (if 2 is the maximum positive vote
+for the code review label). The maximum supported value for `count` is 5.
+`count=0` is not allowed and the query request will fail with `400 Bad Request`.
+
+`label:Code-Review=+1,count>=2`::
++
+Matches changes having two or more +1 votes to the code-review label. Can also
+be used with the {MAX, MIN, ANY} label votes. All operators `>`, `>=`, `<`, `<=`
+are supported.
+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` (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`::
@@ -815,7 +864,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/WORKSPACE b/WORKSPACE
index b3bf30b..fe6b94e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -31,6 +31,7 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
 load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
+load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
 
 http_archive(
     name = "platforms",
@@ -53,10 +54,10 @@
 
 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",
     ],
 )
 
@@ -116,10 +117,10 @@
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
+    sha256 = "d6b2513456fe2229811da7eb67a444be7785f5323c6708b38d851d2b51e54d83",
     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",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.30.0/rules_go-v0.30.0.zip",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.30.0/rules_go-v0.30.0.zip",
     ],
 )
 
@@ -127,14 +128,14 @@
 
 go_rules_dependencies()
 
-go_register_toolchains()
+go_register_toolchains(version = "1.17.6")
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
+    sha256 = "de69a09dc70417580aabf20a28619bb3ef60d038470c7cf8442fafcf627c21cb",
     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",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
     ],
 )
 
@@ -149,91 +150,19 @@
     importpath = "github.com/howeyc/fsnotify",
 )
 
+register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
+
+register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
+
 # JGit external repository consumed from git submodule
 local_repository(
     name = "jgit",
     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 = "javax.servlet:javax.servlet-api:3.1.0",
-    sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
-)
-
-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.
@@ -253,650 +182,8 @@
     ],
 )
 
-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",
-)
-
-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.1"
-
-maven_jar(
-    name = "auto-value-gson-runtime",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
-)
-
-maven_jar(
-    name = "auto-value-gson-extension",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "0c4c01a3e10e5b10df2e5f5697efa4bb3f453ac1",
-)
-
-maven_jar(
-    name = "auto-value-gson-factory",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "9ed8d79144ee8d60cc94cc11f847b5ed8ee9f19c",
-)
-
-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",
-)
-
-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",
-)
-
-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 = "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", "node_repositories", "yarn_install")
 
 node_repositories(
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index 1bf20aa..0994d9b 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -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/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..e26fc00 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)
   }
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..5d5f5d5 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.scenarios
 
 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 +30,11 @@
   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)
+    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..7d7bed7 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,12 @@
 
 package com.google.gerrit.scenarios
 
+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 3934a6a..903b709 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,6 +26,7 @@
 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;
@@ -38,12 +39,15 @@
 
 import com.github.rholder.retry.BlockStrategy;
 import com.google.common.base.Strings;
+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;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
@@ -78,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;
@@ -95,8 +100,6 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -123,11 +126,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;
@@ -163,7 +162,6 @@
 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;
@@ -290,6 +288,7 @@
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected BatchAbandon batchAbandon;
   @Inject protected TestSshKeys sshKeys;
+  @Inject protected TestTicker testTicker;
 
   protected EventRecorder eventRecorder;
   protected GerritServer server;
@@ -310,14 +309,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;
@@ -579,8 +575,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();
@@ -655,7 +650,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);
@@ -702,6 +700,8 @@
     }
     SystemReader.setInstance(oldSystemReader);
     oldSystemReader = null;
+    // Set useDefaultTicker in afterTest, so the next beforeTest will use the default ticker
+    testTicker.useDefaultTicker();
   }
 
   protected void closeSsh() {
@@ -1063,87 +1063,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();
   }
@@ -1194,9 +1113,37 @@
   }
 
   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))
@@ -1250,10 +1197,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);
-    }
+  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);
   }
 
   /**
@@ -1608,6 +1555,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());
   }
@@ -1763,4 +1717,32 @@
           moduleClass.getName());
     }
   }
+
+  /** {@link Ticker} implementation for mocking without restarting GerritServer */
+  public static class TestTicker extends Ticker {
+    Ticker actualTicker;
+
+    public TestTicker() {
+      useDefaultTicker();
+    }
+
+    /** Switches to system ticker */
+    public Ticker useDefaultTicker() {
+      this.actualTicker = Ticker.systemTicker();
+      return actualTicker;
+    }
+
+    /** Switches to {@link FakeTicker} */
+    public FakeTicker useFakeTicker() {
+      if (!(this.actualTicker instanceof FakeTicker)) {
+        this.actualTicker = new FakeTicker();
+      }
+      return (FakeTicker) actualTicker;
+    }
+
+    @Override
+    public long read() {
+      return actualTicker.read();
+    }
+  }
 }
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/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/BUILD b/java/com/google/gerrit/acceptance/BUILD
index d6dff8f..4298663 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",
@@ -123,6 +121,7 @@
     "//lib/truth",
     "//lib/truth:truth-java8-extension",
     "//lib/greenmail",
+    "//lib:guava-testlib",
 ] + TEST_DEPS
 
 java_library(
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 3844788..0acf3bc 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -14,6 +14,7 @@
 
 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;
@@ -22,6 +23,7 @@
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 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;
@@ -49,6 +51,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;
@@ -71,6 +75,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;
@@ -78,6 +83,7 @@
   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;
@@ -98,6 +104,9 @@
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
 
+  private final DynamicMap<ChangeHasOperandFactory> hasOperands;
+  private final DynamicMap<ChangeIsOperandFactory> isOperands;
+
   @Inject
   ExtensionRegistry(
       DynamicSet<AccountIndexedListener> accountIndexedListeners,
@@ -110,6 +119,7 @@
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
       DynamicSet<SubmitRule> submitRules,
+      DynamicSet<SubmitRequirement> submitRequirements,
       DynamicSet<ChangeMessageModifier> changeMessageModifiers,
       DynamicSet<ChangeETagComputation> changeETagComputations,
       DynamicSet<ActionVisitor> actionVisitors,
@@ -117,6 +127,7 @@
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       DynamicSet<CommentAddedListener> commentAddedListeners,
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
+      DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
       DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
@@ -134,7 +145,9 @@
       DynamicSet<PluginPushOption> pluginPushOption,
       DynamicSet<OnPostReview> onPostReviews,
       DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
-      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners) {
+      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
+      DynamicMap<ChangeHasOperandFactory> hasOperands,
+      DynamicMap<ChangeIsOperandFactory> isOperands) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -145,6 +158,7 @@
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.submitRules = submitRules;
+    this.submitRequirements = submitRequirements;
     this.changeMessageModifiers = changeMessageModifiers;
     this.changeETagComputations = changeETagComputations;
     this.actionVisitors = actionVisitors;
@@ -152,6 +166,7 @@
     this.refOperationValidationListeners = refOperationValidationListeners;
     this.commentAddedListeners = commentAddedListeners;
     this.refUpdatedListeners = refUpdatedListeners;
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
@@ -170,6 +185,8 @@
     this.onPostReviews = onPostReviews;
     this.reviewerAddedListeners = reviewerAddedListeners;
     this.reviewerDeletedListeners = reviewerDeletedListeners;
+    this.hasOperands = hasOperands;
+    this.isOperands = isOperands;
   }
 
   public Registration newRegistration() {
@@ -220,6 +237,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);
     }
@@ -252,6 +281,10 @@
       return add(refUpdatedListeners, refUpdatedListener);
     }
 
+    public Registration add(GitBatchRefUpdateListener batchRefUpdateListener) {
+      return add(batchRefUpdateListeners, batchRefUpdateListener);
+    }
+
     public Registration add(FileHistoryWebLink fileHistoryWebLink) {
       return add(fileHistoryWebLinks, fileHistoryWebLink);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index f13f02e..a149f29 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -22,7 +22,9 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.base.Ticker;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest.TestTicker;
 import com.google.gerrit.acceptance.FakeGroupAuditService.FakeGroupAuditServiceModule;
 import com.google.gerrit.acceptance.ReindexGroupsAtStartup.ReindexGroupsAtStartupModule;
 import com.google.gerrit.acceptance.ReindexProjectsAtStartup.ReindexProjectsAtStartupModule;
@@ -76,6 +78,7 @@
 import com.google.inject.Module;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
+import com.google.inject.multibindings.OptionalBinder;
 import java.lang.annotation.Annotation;
 import java.lang.annotation.Retention;
 import java.lang.reflect.Field;
@@ -430,6 +433,23 @@
                 .to(GitObjectVisibilityChecker.class);
           }
         });
+    daemon.addAdditionalSysModuleForTesting(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            super.configure();
+            // GerritServer isn't restarted between tests. TestTicker allows to replace actual
+            // Ticker in tests without restarting server and transparently for other code.
+            // Alternative option with Provider<Ticker> is less convinient, because it affects how
+            // gerrit code should be written - i.e. Ticker must not be stored in fields and must
+            // always be obtained from the provider.
+            TestTicker testTicker = new TestTicker();
+            OptionalBinder.newOptionalBinder(binder(), Ticker.class)
+                .setBinding()
+                .toInstance(testTicker);
+            bind(TestTicker.class).toInstance(testTicker);
+          }
+        });
 
     if (desc.memory()) {
       checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
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/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/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
deleted file mode 100644
index f7a0669..0000000
--- a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.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 insert(ChangeData obj) {
-    // do nothing
-  }
-
-  @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 fa4d1d1..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.SshSessionFactory;
-import org.eclipse.jgit.transport.ssh.jsch.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.ssh.jsch.OpenSshConfig.Host;
-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/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..580f10f 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -48,8 +48,8 @@
 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,7 +138,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 changeOwner = getChangeOwner(changeCreation);
       PersonIdent authorAndCommitter =
           changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
@@ -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,13 @@
       return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
     }
 
-    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+    // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+    // Instants
+    @SuppressWarnings("JdkObsolete")
+    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.getWhen().toInstant())) {
         /* 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 +508,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);
+      return new PersonIdent(oldPatchsetCommitter, Timestamp.from(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/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/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..850a133 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -17,7 +17,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 18da4b3..deeb843 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -43,7 +43,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;
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/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index 2947efd..c3870f4 100644
--- a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -125,7 +125,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/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 9a9f309..7699799 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -199,7 +199,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);
     }
   }
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/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/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/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index cd3b27a..303e79f 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;
 
 /**
@@ -123,7 +123,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 +157,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)
@@ -196,9 +196,9 @@
    * <p>Example output:
    *
    * <ul>
-   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
+   *   <li>{@code A U. Thor <author@example.com>}: full populated
    *   <li>{@code A U. Thor (12)}: missing email address
-   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
+   *   <li>{@code Anonymous Coward <author@example.com>}: missing name
    *   <li>{@code Anonymous Coward (12)}: missing name and email address
    * </ul>
    */
@@ -230,9 +230,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/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/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 76e35db..be4a1cf 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -227,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;
     }
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index d1826bc..66e1a96 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.
@@ -438,37 +425,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 +463,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 +550,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;
   }
 
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..65a1559 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;
@@ -88,7 +89,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 +114,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 +181,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 +216,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 +237,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,21 +247,20 @@
   }
 
   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;
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..be28689 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -48,12 +48,12 @@
     for (char i = 'a'; i <= 'f'; i++) hexb[i] = (byte) ((i - 'a') + 10);
   }
 
-  public static String encode(final String e) {
+  public static String encode(final String key) {
     final byte[] b;
     try {
-      b = e.getBytes("UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
+      b = key.getBytes("UTF-8");
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException("No UTF-8 support", e);
     }
 
     final StringBuilder r = new StringBuilder(b.length);
@@ -71,20 +71,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 +97,12 @@
         }
       }
     } 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);
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException("No UTF-8 support", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index d254752..3541aac 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -95,6 +95,8 @@
 
   public abstract String getName();
 
+  public abstract Optional<String> getDescription();
+
   public abstract LabelFunction getFunction();
 
   public abstract boolean isCopyAnyScore();
@@ -141,8 +143,9 @@
   }
 
   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)
@@ -226,6 +229,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);
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/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index b26e5c3..6c52368 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -22,7 +22,8 @@
 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 java.time.Instant;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,6 +42,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);
   }
@@ -159,7 +163,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 +210,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/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/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 2263aba..b9c1b3c 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.UsedAt;
+import java.util.List;
 
 /** Constants and utilities for Gerrit-specific ref names. */
 public class RefNames {
@@ -126,6 +128,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;
   }
@@ -344,16 +348,16 @@
       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 +367,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 +376,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;
@@ -387,20 +391,20 @@
       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;
     }
 
@@ -421,16 +425,16 @@
       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 +444,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 +453,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;
@@ -489,7 +493,7 @@
     return Integer.parseInt(rest.substring(0, ie));
   }
 
-  static Integer parseRefSuffix(String name) {
+  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..dc5abbc 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -58,7 +58,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. */
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 900b2e2..aff0994 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -39,17 +39,17 @@
   /**
    * List leaf predicates that are fulfilled, for example the expression
    *
-   * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+   * <p><i>label:Code-Review=+2 and branch:refs/heads/master</i>
    *
    * <p>has two leaf predicates:
    *
    * <ul>
-   *   <li>label:code-review=+2
+   *   <li>label:Code-Review=+2
    *   <li>branch:refs/heads/master
    * </ul>
    *
    * This method will return the leaf predicates that were fulfilled, for example if only the first
-   * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+   * predicate was fulfilled, the returned list will be equal to ["label:Code-Review=+2"].
    */
   public abstract ImmutableList<String> passingAtoms();
 
@@ -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(
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/MergeUpdateException.java
similarity index 64%
rename from java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
rename to java/com/google/gerrit/exceptions/MergeUpdateException.java
index 452192c..b60ca57 100644
--- a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
+++ b/java/com/google/gerrit/exceptions/MergeUpdateException.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.exceptions;
 
-public class InternalServerWithUserMessageException extends RuntimeException {
+/**
+ * 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 InternalServerWithUserMessageException(String msg, Throwable cause) {
-    super(msg, cause);
+  public MergeUpdateException(String userVisibleMessage, Throwable cause) {
+    super(userVisibleMessage, cause);
   }
 }
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..8432c8f 100644
--- a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -25,4 +25,10 @@
   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;
 }
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/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 1307516..b659cca 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -51,12 +51,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;
@@ -369,11 +363,6 @@
     }
 
     @Override
-    public BinaryResult submitPreview(String format) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public SubmitType testSubmitType(TestSubmitRuleInput in) 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/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/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..6acf3f4 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,
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/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..40ae2ec 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;
@@ -124,4 +131,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/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/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..d3baecd 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -19,6 +19,7 @@
 
 public class LabelDefinitionInfo {
   public String name;
+  public String description;
   public String projectName;
   public String function;
   public Map<String, String> values;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 38b76c1..1d580bb 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -19,6 +19,7 @@
 
 public class LabelDefinitionInput extends InputWithCommitMessage {
   public String name;
+  public String description;
   public String function;
   public Map<String, String> values;
   public Short defaultValue;
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/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..b4731f2 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 /** Result of evaluating a single submit requirement expression. */
 public class SubmitRequirementExpressionInfo {
@@ -36,4 +37,31 @@
    * 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;
+
+  @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/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/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index d827d5d..cb9d855 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
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @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.getWhen().getTime());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
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/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..a0b2c6a 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -153,7 +153,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 +170,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
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_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/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/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/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 27530e7..0a96212 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -32,9 +32,9 @@
 import com.google.common.flogger.FluentLogger;
 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 +62,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 +111,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 +183,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 +197,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 +216,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 +226,14 @@
     // 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);
   }
 
   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();
@@ -305,7 +308,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;
@@ -469,4 +472,9 @@
     }
     return null;
   }
+
+  @SuppressWarnings("JdkObsolete")
+  private static Instant getCreationTime(PGPPublicKey key) {
+    return key.getCreationTime().toInstant();
+  }
 }
diff --git a/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 36a4af7..17ca5a4 100644
--- a/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -25,6 +25,7 @@
 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 +107,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));
     }
 
@@ -205,7 +206,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 +217,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/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..3341806 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -164,7 +164,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 +179,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 +207,7 @@
         throw new BadRequestException("Failed to parse GPG keys", e);
       }
     }
-    return keyRings;
+    return ImmutableList.copyOf(keyRings);
   }
 
   private void storeKeys(
@@ -249,7 +250,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);
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index ea7c609..1284829 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -37,7 +37,7 @@
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
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 56afe40..ab6d0f4 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -500,6 +500,7 @@
         }
       } catch (Throwable e) {
         logger.atSevere().withCause(e).log(
+            "%s",
             MessageFormat.format(
                 HttpServerText.get().internalErrorDuringUploadPack,
                 ServletUtils.getRepository(req)));
@@ -527,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();
       }
@@ -634,7 +635,7 @@
         return;
       }
 
-      if (!(userProvider.get().isIdentifiedUser())) {
+      if (!userProvider.get().isIdentifiedUser()) {
         chain.doFilter(request, responseWrapper);
         return;
       }
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..de6ae50 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -158,8 +158,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 +180,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;
     }
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/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/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/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 0875317..8b0023b 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -74,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;
@@ -136,7 +136,7 @@
     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);
@@ -572,9 +572,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);
     }
@@ -700,7 +698,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");
@@ -710,10 +708,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()) {
@@ -754,6 +748,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/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 68cf1b2..0073ec2 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -210,7 +210,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));
       }
 
@@ -345,7 +345,7 @@
         });
     modules.add(new DefaultUrlFormatterModule());
 
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     modules.add(SshKeyCacheImpl.module());
     modules.add(
         new AbstractModule() {
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 97752a0..a03aa36 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -68,7 +68,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 +85,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;
@@ -447,8 +446,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-")) {
@@ -520,7 +518,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);
diff --git a/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
index 5a8fa31..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);
     }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 445a73a..ce22ae8 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -97,7 +97,8 @@
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         break;
       case DIFF:
-        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
+        data.put(
+            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 3bdcb1a..fa9a820 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -89,7 +89,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,13 +104,8 @@
           ListChangesOption.MESSAGES,
           ListChangesOption.SUBMITTABLE,
           ListChangesOption.WEB_LINKS,
-          ListChangesOption.SKIP_DIFFSTAT);
-
-  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
-      ImmutableSet.of(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS,
-          ListChangesOption.SKIP_DIFFSTAT);
+          ListChangesOption.SKIP_DIFFSTAT,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
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/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index aa32169..8e8a9d2 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -79,6 +79,7 @@
           "/dashboard/*",
           "/groups/self",
           "/settings/*",
+          "/topic/*",
           "/Documentation/q/*");
 
   /**
@@ -93,7 +94,8 @@
           "/elements/*",
           "/fonts/*",
           "/scripts/*",
-          "/styles/*");
+          "/styles/*",
+          "/workers/*");
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index d981a45..543e794 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -825,7 +825,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);
     }
@@ -1714,7 +1714,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/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index 098bccb..23ae312 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -109,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) {
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index eb04099..3b83478 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.index.query;
 
 public interface DataSource<T> extends HasCardinality {
-  /** Returns read from the database and return the results. */
+  /** 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/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 18d7fbc..b65fb96 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -35,7 +35,7 @@
    * 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;
 
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index df14e29..ee25ef9 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -108,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()) {
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/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/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/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 ec90d21..21d4c2e 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -267,7 +267,7 @@
                 pageSizeMultiplier,
                 limit,
                 getRequestedFields());
-        logger.atFine().log("Query options: " + opts);
+        logger.atFine().log("Query options: %s", opts);
         Predicate<T> pred = rewriter.rewrite(q, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 42f8aa8..29d6f22 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -17,13 +17,13 @@
 import com.google.gerrit.index.FieldDef;
 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);
@@ -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/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 4197c64..4241828 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -48,7 +48,7 @@
 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;
@@ -142,7 +142,7 @@
       }
       queryCount++;
     }
-    return new DataSource<V>() {
+    return new DataSource<>() {
       @Override
       public int getCardinality() {
         return results.size();
@@ -241,7 +241,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();
     }
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/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..d35b8fb
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
@@ -0,0 +1,150 @@
+// 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"})
+  @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/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 1999270..a190ebf 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;
         }
@@ -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)) {
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 6cede89..586887b 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -109,6 +109,7 @@
   private final AutoFlush autoFlush;
   private ScheduledExecutorService autoCommitExecutor;
 
+  @SuppressWarnings("ThreadPriorityCheck")
   AbstractLuceneIndex(
       Schema<V> schema,
       SitePaths sitePaths,
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 512804b..daa921c 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -399,7 +399,7 @@
       }
       ImmutableList<FieldBundle> fieldBundles =
           documents.stream().map(rawDocumentMapper).collect(toImmutableList());
-      return new ResultSet<FieldBundle>() {
+      return new ResultSet<>() {
         @Override
         public Iterator<FieldBundle> iterator() {
           return fieldBundles.iterator();
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 7d82bf5..e1b56c6 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -33,7 +33,6 @@
 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;
@@ -232,15 +231,17 @@
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
       return longRangeQuery.get(
-          r.getField().getName(), r.getMinTimestamp().getTime(), r.getMaxTimestamp().getTime());
+          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) {
+    if (r.getMinTimestamp().toEpochMilli() == 0) {
       return longRangeQuery.get(
-          r.getField().getName(), r.getMaxTimestamp().getTime(), Long.MAX_VALUE);
+          r.getField().getName(), r.getMaxTimestamp().toEpochMilli(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -279,10 +280,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/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/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/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/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/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/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/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 3a1de5e..df64bc7 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -48,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/CopyApprovals.java b/java/com/google/gerrit/pgm/CopyApprovals.java
deleted file mode 100644
index f62d60c..0000000
--- a/java/com/google/gerrit/pgm/CopyApprovals.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// 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.pgm;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.index.IndexType;
-import com.google.gerrit.lifecycle.LifecycleManager;
-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.approval.RecursiveApprovalCopier;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.IndexModule;
-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.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-
-public class CopyApprovals extends SiteProgram {
-
-  private Injector dbInjector;
-  private Injector sysInjector;
-  private Injector cfgInjector;
-  private Config globalConfig;
-
-  @Inject private RecursiveApprovalCopier recursiveApprovalCopier;
-
-  @Override
-  public int run() throws Exception {
-    mustHaveValidSite();
-    dbInjector = createDbInjector();
-    cfgInjector = dbInjector.createChildInjector();
-    globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    LifecycleManager dbManager = new LifecycleManager();
-    dbManager.add(dbInjector);
-    dbManager.start();
-
-    sysInjector = createSysInjector();
-    sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
-    LifecycleManager sysManager = new LifecycleManager();
-    sysManager.add(sysInjector);
-    sysManager.start();
-
-    sysInjector.injectMembers(this);
-
-    try {
-      recursiveApprovalCopier.persistStandalone();
-      return 0;
-    } catch (Exception e) {
-      throw die(e.getMessage(), e);
-    } finally {
-      sysManager.stop();
-      dbManager.stop();
-    }
-  }
-
-  private Injector createSysInjector() {
-    Map<String, Integer> versions = new HashMap<>();
-    boolean replica = ReplicaUtil.isReplica(globalConfig);
-    List<Module> modules = new ArrayList<>();
-    Module indexModule;
-    IndexType indexType = IndexModule.getIndexType(dbInjector);
-    if (indexType.isLucene()) {
-      indexModule =
-          LuceneIndexModule.singleVersionWithExplicitVersions(
-              versions, 1, 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.
-      try {
-        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
-        Method m =
-            clazz.getMethod(
-                "singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
-        indexModule = (Module) m.invoke(null, versions, 1, replica);
-      } catch (NoSuchMethodException
-          | ClassNotFoundException
-          | IllegalAccessException
-          | InvocationTargetException e) {
-        throw new IllegalStateException("can't create index", e);
-      }
-    } else {
-      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 FactoryModule() {
-          @Override
-          protected void configure() {
-            factory(ChangeResource.Factory.class);
-          }
-        });
-    modules.add(new BatchProgramModule(dbInjector));
-
-    return dbInjector.createChildInjector(
-        ModuleOverloader.override(
-            modules, LibModuleLoader.loadReindexModules(cfgInjector, versions, 1, replica)));
-  }
-}
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index d77daa1..75891fe 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -493,7 +493,7 @@
           });
     }
     modules.add(new DefaultUrlFormatterModule());
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
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/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 733c9d1..824a9a7 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -196,7 +196,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/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index d9e3a6a..c56a8d9 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -30,6 +30,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.Date;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheEditor;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
@@ -60,12 +61,16 @@
     this.allUsers = allUsers.get();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public Account insert(Account.Builder account) throws IOException {
     File path = getPath();
     try (Repository repo = new FileRepository(path);
         ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
+          new PersonIdent(
+              new GerritPersonIdentProvider(flags.cfg).get(), Date.from(account.registeredOn()));
 
       Config accountConfig = new Config();
       AccountProperties.writeToAccountConfig(
@@ -110,7 +115,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/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index c1f5753..4592cbb 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -124,7 +124,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);
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 9b4d5bb..35892f2 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -64,7 +64,7 @@
     if (path != null) {
       try (Repository allUsersRepo = new FileRepository(path)) {
         ExternalIdNotes extIdNotes =
-            ExternalIdNotes.loadNoCacheUpdate(
+            ExternalIdNotes.load(
                 allUsers,
                 allUsersRepo,
                 externalIdFactory,
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 2f12abb..020705e 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -41,6 +41,7 @@
 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,10 +166,14 @@
     return AuditLogFormatter.createBackedBy(ImmutableSet.of(account), ImmutableSet.of(), serverId);
   }
 
-  private void commit(Repository repository, GroupConfig groupConfig, Timestamp groupCreatedOn)
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
+  private void commit(Repository repository, GroupConfig groupConfig, Instant groupCreatedOn)
       throws IOException {
     PersonIdent personIdent =
-        new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
+        new PersonIdent(
+            new GerritPersonIdentProvider(flags.cfg).get(), Timestamp.from(groupCreatedOn));
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(repository, personIdent)) {
       groupConfig.commit(metaDataUpdate);
     }
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index d6a0133..4e854b5 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -120,7 +120,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 =
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/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 c397539..208ed1f 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;
@@ -41,7 +42,6 @@
 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;
@@ -83,11 +83,13 @@
 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.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
@@ -180,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());
@@ -197,6 +198,7 @@
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
@@ -210,6 +212,11 @@
     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);
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/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..7a6187d 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -111,9 +111,9 @@
         "//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",
@@ -144,6 +144,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 ba9f6d6..8198ce4 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -44,17 +44,20 @@
 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;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -64,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()
@@ -78,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()
@@ -130,7 +133,7 @@
   public HumanComment newHumanComment(
       ChangeNotes changeNotes,
       CurrentUser currentUser,
-      Timestamp when,
+      Instant when,
       String path,
       PatchSet.Id psId,
       short side,
@@ -302,7 +305,7 @@
   }
 
   private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
-    return c.updated.after(cm.getWrittenOn());
+    return c.getUpdated().isAfter(cm.getWrittenOn());
   }
 
   /**
@@ -335,7 +338,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);
     }
@@ -438,7 +441,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();
@@ -465,10 +468,35 @@
     }
   }
 
+  /** returns all changes that contain draft comments of {@code accountId}. */
+  public Collection<Change.Id> getChangesWithDrafts(Account.Id accountId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getChangesWithDrafts(repo, accountId);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
   private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
     return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(changeId));
   }
 
+  private Collection<Change.Id> getChangesWithDrafts(Repository repo, Account.Id accountId)
+      throws IOException {
+    Set<Change.Id> changes = new HashSet<>();
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
+      Integer accountIdFromRef = RefNames.parseRefSuffix(ref.getName());
+      if (accountIdFromRef != null && accountIdFromRef == accountId.get()) {
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (changeId == null) {
+          continue;
+        }
+        changes.add(changeId);
+      }
+    }
+    return changes;
+  }
+
   private static <T extends Comment> List<T> sort(List<T> comments) {
     comments.sort(COMMENT_ORDER);
     return comments;
diff --git a/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
index 2b48169..e7fd1c5 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;
 
 /**
@@ -26,11 +25,14 @@
  * static utility methods.
  */
 public class CommonConverters {
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public static GitPerson toGitPerson(PersonIdent ident) {
     GitPerson result = new GitPerson();
     result.name = ident.getName();
     result.email = ident.getEmailAddress();
-    result.date = new Timestamp(ident.getWhen().getTime());
+    result.setDate(ident.getWhen().toInstant());
     result.tz = ident.getTimeZoneOffset();
     return result;
   }
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..122e18d 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -49,7 +49,7 @@
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
-import java.util.Date;
+import java.time.Instant;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TimeZone;
@@ -427,10 +427,10 @@
   }
 
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(new Date(), TimeZone.getDefault());
+    return newRefLogIdent(Instant.now(), TimeZone.getDefault());
   }
 
-  public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
+  public PersonIdent newRefLogIdent(Instant when, TimeZone tz) {
     final Account ua = getAccount();
 
     String name = ua.fullName();
@@ -450,14 +450,22 @@
               ? constructMailAddress(ua, "unknown")
               : ua.preferredEmail();
     }
-    return new PersonIdent(name, user, when, tz);
+
+    return newPersonIdent(name, user, when, tz);
   }
 
   private String constructMailAddress(Account ua, String host) {
     return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
-  public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
+  public PersonIdent newCommitterIdent(PersonIdent ident) {
+    return newCommitterIdent(ident.getWhen().toInstant(), ident.getTimeZone());
+  }
+
+  public PersonIdent newCommitterIdent(Instant when, TimeZone tz) {
     final Account ua = getAccount();
     String name = ua.fullName();
     String email = ua.preferredEmail();
@@ -492,7 +500,7 @@
       }
     }
 
-    return new PersonIdent(name, email, when, tz);
+    return newPersonIdent(name, email, when, tz);
   }
 
   @Override
@@ -560,4 +568,19 @@
     }
     return host;
   }
+
+  /**
+   * Create a {@link PersonIdent} from an {@code Instant} and a {@link TimeZone}.
+   *
+   * <p>We use the {@link PersonIdent#PersonIdent(String, String, long, int)} constructor to avoid
+   * doing a conversion to {@code java.util.Date} here. For the {@code int aTZ} argument, which is
+   * the time zone, we do the same computation as in {@link PersonIdent#PersonIdent(String, String,
+   * java.util.Date, TimeZone)} (just instead of getting the epoch millis from {@code
+   * java.util.Date} we get them from {@link Instant}).
+   */
+  // TODO(issue-15517): Drop this method once JGit's PersonIdent class supports Instants
+  private static PersonIdent newPersonIdent(String name, String email, Instant when, TimeZone tz) {
+    return new PersonIdent(
+        name, email, when.toEpochMilli(), tz.getOffset(when.toEpochMilli()) / (60 * 1000));
+  }
 }
diff --git a/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
index f7e04b4..5c0f8e4 100644
--- a/java/com/google/gerrit/server/LibModuleLoader.java
+++ b/java/com/google/gerrit/server/LibModuleLoader.java
@@ -87,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/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 326ddf4..3d449b7 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -39,7 +39,6 @@
 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.Optional;
 import java.util.Set;
@@ -106,7 +105,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))
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/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 f7193b7..95d891e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -54,10 +54,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.HashSet;
+import java.util.Collections;
 import java.util.List;
+import java.util.NavigableSet;
 import java.util.Set;
-import java.util.SortedSet;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
@@ -113,7 +113,7 @@
   @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 +123,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();
@@ -185,7 +185,7 @@
     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,7 +197,7 @@
     }
   }
 
-  public ImmutableSortedSet<String> star(
+  public NavigableSet<String> star(
       Account.Id accountId,
       Project.NameKey project,
       Change.Id changeId,
@@ -208,7 +208,7 @@
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
 
-      Set<String> labels = new HashSet<>(old.labels());
+      NavigableSet<String> labels = new TreeSet<>(old.labels());
       if (labelsToAdd != null) {
         labels.addAll(labelsToAdd);
       }
@@ -224,7 +224,7 @@
       }
 
       indexer.index(project, changeId);
-      return ImmutableSortedSet.copyOf(labels);
+      return Collections.unmodifiableNavigableSet(labels);
     } catch (IOException e) {
       throw new StorageException(
           String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
@@ -289,6 +289,35 @@
     }
   }
 
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, String label) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
+        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
+        // Skip all refs that don't correspond with accountId.
+        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
+          continue;
+        }
+        // Skip all refs that don't contain the required label.
+        StarRef starRef = readLabels(repo, ref.getName());
+        if (!starRef.labels().contains(label)) {
+          continue;
+        }
+
+        // Skip invalid change ids.
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (changeId == null) {
+          continue;
+        }
+        builder.add(changeId);
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Get starred changes for account %d failed", accountId.get()), e);
+    }
+  }
+
   public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
     List<ChangeData> changeData =
         queryProvider
@@ -397,7 +426,7 @@
       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 2cf2326..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));
   }
 
   /**
@@ -179,7 +174,7 @@
     }
     return Streams.stream(fileHistoryLinks)
         .map(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -218,7 +213,7 @@
                     patchSetIdB,
                     revisionB,
                     fileB))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -255,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/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 093af68..1d9150d 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -153,7 +153,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..28e881e1 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -34,8 +34,9 @@
 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.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -177,7 +178,7 @@
    * @throws DuplicateKeyException if the user branch already exists
    */
   public Account getNewAccount() throws DuplicateKeyException {
-    return getNewAccount(TimeUtil.nowTs());
+    return getNewAccount(TimeUtil.now());
   }
 
   /**
@@ -186,7 +187,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 +217,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 =
@@ -257,6 +258,9 @@
     return c;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -274,9 +278,9 @@
         commit.setMessage("Create account\n");
       }
 
-      Timestamp registeredOn = loadedAccountProperties.get().getRegisteredOn();
-      commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
-      commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
+      Instant registeredOn = loadedAccountProperties.get().getRegisteredOn();
+      commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(registeredOn)));
+      commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(registeredOn)));
     }
 
     saveAccount();
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index 3f7f3f2..f5f9b3d 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;
@@ -259,10 +258,7 @@
   private boolean viewAll() {
     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/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..8824d56 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;
@@ -443,9 +442,10 @@
       // more strict here.
       boolean canSeeSecondaryEmails = false;
       try {
-        permissionBackend.user(self.get()).check(GlobalPermission.MODIFY_ACCOUNT);
-        canSeeSecondaryEmails = true;
-      } catch (AuthException | PermissionBackendException e) {
+        if (permissionBackend.user(self.get()).test(GlobalPermission.MODIFY_ACCOUNT)) {
+          canSeeSecondaryEmails = true;
+        }
+      } catch (PermissionBackendException e) {
         // remains false
       }
       return accountQueryProvider.get().enforceVisibility(true)
@@ -538,12 +538,12 @@
    * @throws IOException if an error occurs.
    */
   public Result resolve(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate());
+    return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::isActive);
   }
 
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
+    return searchImpl(input, searchers, this::canSeePredicate, accountActivityPredicate);
   }
 
   /**
@@ -556,17 +556,17 @@
    * 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, this::canSeePredicate, AccountResolver::allVisible);
   }
 
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
+    return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::isActive);
   }
 
   public Result resolveIgnoreVisibility(
       String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate);
+    return searchImpl(input, searchers, this::allVisiblePredicate, accountActivityPredicate);
   }
 
   /**
@@ -595,7 +595,7 @@
   @Deprecated
   public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
     return searchImpl(
-        input, nameOrEmailSearchers, visibilitySupplierCanSee(), accountActivityPredicate());
+        input, nameOrEmailSearchers, this::canSeePredicate, AccountResolver::isActive);
   }
 
   /**
@@ -614,26 +614,29 @@
     return searchImpl(
         input,
         ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
-        visibilitySupplierCanSee(),
-        accountActivityPredicate());
+        this::canSeePredicate,
+        AccountResolver::isActive);
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierCanSee() {
-    return () -> accountControlFactory.get()::canSee;
+  private Predicate<AccountState> canSeePredicate() {
+    return this::canSee;
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierAll() {
-    return () -> all();
+  private boolean canSee(AccountState accountState) {
+    return accountControlFactory.get().canSee(accountState);
   }
 
-  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
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..3ee6365 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;
@@ -201,8 +200,6 @@
   /** Single instance that accumulates updates from the batch. */
   private ExternalIdNotes externalIdNotes;
 
-  private static final Runnable DO_NOTHING = () -> {};
-
   @AssistedInject
   @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
@@ -225,8 +222,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.empty()),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @AssistedInject
@@ -252,8 +249,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @VisibleForTesting
@@ -297,9 +294,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;
   }
 
   /**
@@ -324,6 +319,9 @@
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
     return execute(
@@ -331,8 +329,7 @@
                 repo -> {
                   AccountConfig accountConfig = read(repo, accountId);
                   Account account =
-                      accountConfig.getNewAccount(
-                          new Timestamp(committerIdent.getWhen().getTime()));
+                      accountConfig.getNewAccount(committerIdent.getWhen().toInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
                   init.configure(accountState, deltaBuilder);
@@ -586,6 +583,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 50ed532..cceda70 100644
--- a/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -49,20 +49,20 @@
     }
 
     /** Create a request for a local username, such as from LDAP. */
-    public AuthRequest createForUser(String username) {
+    public AuthRequest createForUser(String userName) {
       AuthRequest r =
           new AuthRequest(
-              externalIdKeyFactory.create(SCHEME_GERRIT, username), externalIdKeyFactory);
-      r.setUserName(username);
+              externalIdKeyFactory.create(SCHEME_GERRIT, userName), externalIdKeyFactory);
+      r.setUserName(userName);
       return r;
     }
 
     /** Create a request for an external username. */
-    public AuthRequest createForExternalUser(String username) {
+    public AuthRequest createForExternalUser(String userName) {
       AuthRequest r =
           new AuthRequest(
-              externalIdKeyFactory.create(SCHEME_EXTERNAL, username), externalIdKeyFactory);
-      r.setUserName(username);
+              externalIdKeyFactory.create(SCHEME_EXTERNAL, userName), externalIdKeyFactory);
+      r.setUserName(userName);
       return r;
     }
 
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/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 98d0d50..45f0844 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;
@@ -114,11 +113,14 @@
     return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public UserIdentity toUserIdentity(PersonIdent who) throws IOException {
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
-    u.setDate(new Timestamp(who.getWhen().getTime()));
+    u.setDate(who.getWhen().toInstant());
     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/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/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/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 0a23a10..0a51171 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -310,7 +310,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..9f766d0 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;
 
@@ -32,21 +31,6 @@
  * <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);
-
   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..27672bd 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;
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
index a59e935..a6ee366c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
@@ -127,11 +127,11 @@
                   isUserNameCaseInsensitive ? "" : "in"));
           extIdNotes.commit(metaDataUpdate);
         } catch (Exception e) {
-          logger.atSevere().withCause(e).log(e.getMessage());
+          logger.atSevere().withCause(e).log("%s", e.getMessage());
         }
       }
     } catch (DuplicateExternalIdKeyException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      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 bd9c7df..b16f73f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -19,7 +19,6 @@
 
 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;
@@ -33,7 +32,6 @@
 
 @Singleton
 public class ExternalIdFactory {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final ExternalIdKeyFactory externalIdKeyFactory;
   private AuthConfig authConfig;
 
@@ -264,7 +262,8 @@
         throw invalidConfig(
             noteId,
             String.format(
-                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID '%s'",
+                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
+                    + " '%s'",
                 externalIdKeyStr, noteId));
       }
       externalIdKey =
@@ -316,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/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index aa37451..b0618ba 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -167,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())
@@ -331,14 +320,13 @@
             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.
    *
@@ -346,7 +334,7 @@
    *
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
-  public static ExternalIdNotes loadNoCacheUpdate(
+  public static ExternalIdNotes load(
       AllUsersName allUsersName,
       Repository allUsersRepo,
       ExternalIdFactory externalIdFactory,
@@ -359,7 +347,6 @@
             DynamicMap.emptyMap(),
             externalIdFactory,
             isUserNameCaseInsensitiveMigrationMode)
-        .setNoCacheUpdate()
         .setNoReindex()
         .load();
   }
@@ -393,7 +380,6 @@
 
   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 =
@@ -450,11 +436,6 @@
     return this;
   }
 
-  private ExternalIdNotes setNoCacheUpdate() {
-    noCacheUpdate = true;
-    return this;
-  }
-
   private ExternalIdNotes setNoReindex() {
     noReindex = true;
     return this;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index e5aace0..fb7f6c4 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -103,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);
@@ -182,10 +188,4 @@
           .get(key);
     }
   }
-
-  private void checkReadEnabled() throws IOException {
-    if (failOnLoad) {
-      throw new IOException("Reading from external IDs is disabled");
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
index e52991b..72e7e90 100644
--- a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
@@ -70,7 +70,9 @@
   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!");
+          "External IDs online migration requires auth.userNameCaseInsensitive and"
+              + " auth.userNameCaseInsensitiveMigrationMode to be set to true. Skipping"
+              + " migration!");
       return;
     }
     executor.execute(
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 764c46d..9aa9306 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -86,7 +86,6 @@
 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.PutDescription;
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Reviewed;
@@ -119,7 +118,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;
@@ -167,7 +165,6 @@
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
-      PreviewSubmit submitPreview,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
       Files files,
@@ -213,7 +210,6 @@
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
-    this.submitPreview = submitPreview;
     this.files = files;
     this.putReviewed = putReviewed;
     this.deleteReviewed = deleteReviewed;
@@ -270,16 +266,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);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 9521759..5baed86 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -507,7 +507,7 @@
 
   @Override
   public ListRefsRequest<BranchInfo> branches() {
-    return new ListRefsRequest<BranchInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<BranchInfo> get() throws RestApiException {
         try {
@@ -521,7 +521,7 @@
 
   @Override
   public ListRefsRequest<TagInfo> tags() {
-    return new ListRefsRequest<TagInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<TagInfo> get() throws RestApiException {
         try {
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/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
similarity index 83%
rename from java/com/google/gerrit/server/approval/ApprovalInference.java
rename to java/com/google/gerrit/server/approval/ApprovalCopier.java
index 695997a..31380f4 100644
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -28,6 +28,7 @@
 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.QueryParseException;
@@ -39,7 +40,8 @@
 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.patch.DiffOptions;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
@@ -49,8 +51,8 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
@@ -59,15 +61,13 @@
 
 /**
  * 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.
+ * and by additionally copying approvals from the previous patch set. The latter is done by
+ * asserting a change's kind and checking the project config for copy conditions.
  *
- * <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
+ * <p>The result of a copy is stored in NoteDb when a new patch set is created.
  */
 @Singleton
-class ApprovalInference {
+class ApprovalCopier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final DiffOperations diffOperations;
@@ -79,7 +79,7 @@
   private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
-  ApprovalInference(
+  ApprovalCopier(
       DiffOperations diffOperations,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
@@ -97,20 +97,11 @@
   }
 
   /**
-   * Returns all approvals that apply to the given patch set. Honors direct and indirect (approval
-   * on parents) approvals.
+   * Returns all approvals that apply to the given patch set. Honors copied approvals from previous
+   * patch-set.
    */
   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) {
+      ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
     ProjectState project;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -134,10 +125,11 @@
       PatchSetApproval psa,
       PatchSet.Id psId,
       ChangeKind kind,
+      boolean isMerge,
       LabelType type,
-      @Nullable Map<String, FileDiffOutput> baseVsCurrentDiff,
-      @Nullable Map<String, FileDiffOutput> baseVsPriorDiff,
-      @Nullable Map<String, FileDiffOutput> priorVsCurrentDiff) {
+      @Nullable Map<String, ModifiedFile> baseVsCurrentDiff,
+      @Nullable Map<String, ModifiedFile> baseVsPriorDiff,
+      @Nullable Map<String, ModifiedFile> priorVsCurrentDiff) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
 
@@ -281,7 +273,7 @@
               project.getName());
           return true;
         }
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
+        if (isMerge && 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"
@@ -325,11 +317,16 @@
       PatchSetApproval psa,
       PatchSet patchSet,
       LabelType type,
-      ChangeKind changeKind) {
+      ChangeKind changeKind,
+      boolean isMerge,
+      RevWalk revWalk,
+      Config repoConfig) {
     if (!type.getCopyCondition().isPresent()) {
       return false;
     }
-    ApprovalContext ctx = ApprovalContext.create(changeNotes, psa, patchSet, changeKind);
+    ApprovalContext ctx =
+        ApprovalContext.create(
+            changeNotes, psa, patchSet, 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
@@ -346,11 +343,7 @@
   }
 
   private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
-      ChangeNotes notes,
-      ProjectState project,
-      PatchSet patchSet,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
+      ChangeNotes notes, ProjectState project, PatchSet patchSet, RevWalk rw, Config repoConfig) {
     checkState(
         project.getNameKey().equals(notes.getProjectName()),
         "project must match %s, %s",
@@ -360,34 +353,23 @@
     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 =
+    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
         notes.load().getApprovals().get(patchSet.id());
-    approvalsForGivenPatchSet.forEach(psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
+    nonCopiedApprovalsForGivenPatchSet.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();
-    }
+    ImmutableList<PatchSetApproval> priorApprovalsIncludingCopied =
+        notes.load().getApprovalsWithCopied().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.
@@ -398,6 +380,7 @@
             repoConfig,
             priorPatchSet.getValue().commitId(),
             patchSet.commitId());
+    boolean isMerge = isMerge(project.getNameKey(), rw, patchSet);
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         patchSet.id().get(),
@@ -405,11 +388,11 @@
         priorPatchSet.getValue().id().changeId(),
         changeKind);
 
-    Map<String, FileDiffOutput> baseVsCurrent = null;
-    Map<String, FileDiffOutput> baseVsPrior = null;
-    Map<String, FileDiffOutput> priorVsCurrent = null;
+    Map<String, ModifiedFile> baseVsCurrent = null;
+    Map<String, ModifiedFile> baseVsPrior = null;
+    Map<String, ModifiedFile> priorVsCurrent = null;
     LabelTypes labelTypes = project.getLabelTypes();
-    for (PatchSetApproval psa : priorApprovals) {
+    for (PatchSetApproval psa : priorApprovalsIncludingCopied) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
@@ -418,10 +401,11 @@
       if (baseVsCurrent == null
           && type.isPresent()
           && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
-        baseVsCurrent = listModifiedFiles(project, patchSet);
-        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue());
+        baseVsCurrent = listModifiedFiles(project, patchSet, rw, repoConfig);
+        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue(), rw, repoConfig);
         priorVsCurrent =
-            listModifiedFiles(project, priorPatchSet.getValue().commitId(), patchSet.commitId());
+            listModifiedFiles(
+                project, priorPatchSet.getValue().commitId(), patchSet.commitId(), rw, repoConfig);
       }
       if (!type.isPresent()) {
         logger.atFine().log(
@@ -440,11 +424,13 @@
               psa,
               patchSet.id(),
               changeKind,
+              isMerge,
               type.get(),
               baseVsCurrent,
               baseVsPrior,
               priorVsCurrent)
-          && !canCopyBasedOnCopyCondition(notes, psa, patchSet, type.get(), changeKind)) {
+          && !canCopyBasedOnCopyCondition(
+              notes, psa, patchSet, type.get(), changeKind, isMerge, rw, repoConfig)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
@@ -452,18 +438,36 @@
     return resultByUser.values();
   }
 
+  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);
+    }
+  }
+
   /**
    * 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) {
+  private Map<String, ModifiedFile> listModifiedFiles(
+      ProjectState project, PatchSet ps, RevWalk revWalk, Config repoConfig) {
     try {
       Integer parentNum =
           listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
               ? 0
               : 1;
-      return diffOperations.listModifiedFilesAgainstParent(
-          project.getNameKey(), ps.commitId(), parentNum);
+      return diffOperations.loadModifiedFilesAgainstParent(
+          project.getNameKey(),
+          ps.commitId(),
+          parentNum,
+          DiffOptions.DEFAULTS,
+          revWalk,
+          repoConfig);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
@@ -477,10 +481,20 @@
    * 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) {
+  private Map<String, ModifiedFile> listModifiedFiles(
+      ProjectState project,
+      ObjectId sourceCommit,
+      ObjectId targetCommit,
+      RevWalk revWalk,
+      Config repoConfig) {
     try {
-      return diffOperations.listModifiedFiles(project.getNameKey(), sourceCommit, targetCommit);
+      return diffOperations.loadModifiedFiles(
+          project.getNameKey(),
+          sourceCommit,
+          targetCommit,
+          DiffOptions.DEFAULTS,
+          revWalk,
+          repoConfig);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index 77ac2e2..fdcaf69 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -20,16 +20,12 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 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.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-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.Change;
 import com.google.gerrit.entities.LabelId;
@@ -45,6 +41,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -56,10 +53,10 @@
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 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.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -83,7 +80,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))
@@ -98,22 +95,22 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalInference approvalInference;
+  private final ApprovalCopier approvalInference;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
-  private final ApprovalCache approvalCache;
+  private final LabelNormalizer labelNormalizer;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalInference approvalInference,
+      ApprovalCopier approvalInference,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      ApprovalCache approvalCache) {
+      LabelNormalizer labelNormalizer) {
     this.approvalInference = approvalInference;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.approvalCache = approvalCache;
+    this.labelNormalizer = labelNormalizer;
   }
 
   /**
@@ -226,10 +223,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",
@@ -300,7 +294,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()) {
@@ -333,28 +327,18 @@
     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) {
+  public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
+      ChangeNotes notes) {
     return notes.load().getApprovals();
   }
 
-  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);
-  }
-
   /**
    * This method should only be used when we want to dynamically compute the approvals. Generally,
    * the copied approvals are available in {@link ChangeNotes}. However, if the patch-set is just
@@ -369,44 +353,23 @@
       RevWalk revWalk,
       Config repoConfig,
       ChangeUpdate changeUpdate) {
-    Set<PatchSetApproval> current =
-        ImmutableSet.copyOf(notes.getApprovalsWithCopied().get(notes.getCurrentPatchSet().id()));
-    Set<PatchSetApproval> inferred =
-        ImmutableSet.copyOf(approvalInference.forPatchSet(notes, patchSet, revWalk, repoConfig));
-
-    // Exempt granted timestamp from comparisson, otherwise, we would persist the copied
-    // labels every time this method is called.
-    Table<LabelId, Account.Id, Short> approvalTable = HashBasedTable.create();
-    for (PatchSetApproval psa : current) {
-      Account.Id id = psa.accountId();
-      approvalTable.put(psa.labelId(), id, psa.value());
-    }
-
-    for (PatchSetApproval psa : inferred) {
-      if (psa.value() != 0) {
-        if (approvalTable.contains(psa.labelId(), psa.accountId())) {
-          Short v = approvalTable.get(psa.labelId(), psa.accountId());
-          if (v.shortValue() != psa.value()) {
-            changeUpdate.putCopiedApproval(psa);
-          }
-        } else {
-          changeUpdate.putCopiedApproval(psa);
-        }
-      }
-    }
+    approvalInference
+        .forPatchSet(notes, patchSet, revWalk, repoConfig)
+        .forEach(a -> changeUpdate.putCopiedApproval(a));
   }
 
+  /**
+   * 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) {
-    return approvalCache.get(notes, psId);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSetUser(
-      ChangeNotes notes,
-      PatchSet.Id psId,
-      Account.Id accountId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
-    return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
+    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovalsWithCopied().get(psId);
+    return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
@@ -419,8 +382,8 @@
       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;
     }
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/RecursiveApprovalCopier.java b/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
deleted file mode 100644
index 24595674..0000000
--- a/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
+++ /dev/null
@@ -1,309 +0,0 @@
-// 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.approval;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.server.FanOutExecutor;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Consumer;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.internal.storage.file.RefDirectory;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-public class RecursiveApprovalCopier {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final int SLICE_MAX_REFS = 1000;
-
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final GitRepositoryManager repositoryManager;
-  private final InternalUser.Factory internalUserFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ListeningExecutorService executor;
-
-  private final ConcurrentHashMap<Project.NameKey, List<ReceiveCommand>> pendingRefUpdates =
-      new ConcurrentHashMap<>();
-
-  private volatile boolean failedForAtLeastOneProject;
-
-  private final AtomicInteger totalCopyApprovalsTasks = new AtomicInteger();
-  private final AtomicInteger finishedCopyApprovalsTasks = new AtomicInteger();
-
-  private final AtomicInteger totalRefUpdates = new AtomicInteger();
-  private final AtomicInteger finishedRefUpdates = new AtomicInteger();
-
-  @Inject
-  public RecursiveApprovalCopier(
-      BatchUpdate.Factory batchUpdateFactory,
-      GitRepositoryManager repositoryManager,
-      InternalUser.Factory internalUserFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeNotes.Factory changeNotesFactory,
-      @FanOutExecutor ExecutorService executor) {
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.repositoryManager = repositoryManager;
-    this.internalUserFactory = internalUserFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.changeNotesFactory = changeNotesFactory;
-    this.executor = MoreExecutors.listeningDecorator(executor);
-  }
-
-  /**
-   * This method assumes it is used as a standalone program having exclusive access to the Git
-   * repositories. Therefore, it will (safely) skip locking of the loose refs when performing batch
-   * ref-updates.
-   */
-  public void persistStandalone()
-      throws RepositoryNotFoundException, IOException, InterruptedException, ExecutionException {
-    persist(repositoryManager.list(), null, false);
-
-    if (failedForAtLeastOneProject) {
-      throw new RuntimeException("There were errors, check the logs for details");
-    }
-  }
-
-  public void persist(Project.NameKey project, @Nullable Consumer<Change> labelsCopiedListener)
-      throws IOException, RepositoryNotFoundException, InterruptedException, ExecutionException {
-    persist(ImmutableList.of(project), labelsCopiedListener, true);
-  }
-
-  private void persist(
-      Collection<Project.NameKey> projects,
-      @Nullable Consumer<Change> labelsCopiedListener,
-      boolean shouldLockLooseRefs)
-      throws InterruptedException, ExecutionException, RepositoryNotFoundException, IOException {
-    List<ListenableFuture<Void>> copyApprovalsTasks = new LinkedList<>();
-    for (Project.NameKey project : projects) {
-      copyApprovalsTasks.addAll(submitCopyApprovalsTasks(project, labelsCopiedListener));
-    }
-    Futures.successfulAsList(copyApprovalsTasks).get();
-
-    List<ListenableFuture<Void>> batchRefUpdateTasks =
-        submitBatchRefUpdateTasks(shouldLockLooseRefs);
-    Futures.successfulAsList(batchRefUpdateTasks).get();
-  }
-
-  private List<ListenableFuture<Void>> submitCopyApprovalsTasks(
-      Project.NameKey project, @Nullable Consumer<Change> labelsCopiedListener)
-      throws RepositoryNotFoundException, IOException {
-    List<ListenableFuture<Void>> futures = new LinkedList<>();
-    try (Repository repository = repositoryManager.openRepository(project)) {
-      ImmutableList<Ref> allMetaRefs =
-          repository.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
-              .filter(r -> r.getName().endsWith(RefNames.META_SUFFIX))
-              .collect(toImmutableList());
-
-      totalCopyApprovalsTasks.addAndGet(allMetaRefs.size());
-
-      for (List<Ref> slice : Lists.partition(allMetaRefs, SLICE_MAX_REFS)) {
-        futures.add(
-            executor.submit(
-                () -> {
-                  copyApprovalsForSlice(project, slice, labelsCopiedListener);
-                  return null;
-                }));
-      }
-    }
-    return futures;
-  }
-
-  private void copyApprovalsForSlice(
-      Project.NameKey project, List<Ref> slice, @Nullable Consumer<Change> labelsCopiedListener)
-      throws Exception {
-    try {
-      copyApprovalsForSlice(project, slice, labelsCopiedListener, false);
-    } catch (Exception e) {
-      failedForAtLeastOneProject = true;
-      logger.atSevere().withCause(e).log(
-          "Error in a slice of project %s, will retry and skip corrupt meta-refs", project);
-      copyApprovalsForSlice(project, slice, labelsCopiedListener, true);
-    }
-    logProgress();
-  }
-
-  private void copyApprovalsForSlice(
-      Project.NameKey project,
-      List<Ref> slice,
-      @Nullable Consumer<Change> labelsCopiedListener,
-      boolean checkForCorruptMetaRefs)
-      throws Exception {
-    logger.atInfo().log("copy-approvals for a slice of %s project", project);
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs())) {
-      for (Ref metaRef : slice) {
-        Change.Id changeId = Change.Id.fromRef(metaRef.getName());
-        if (checkForCorruptMetaRefs && isCorrupt(project, changeId)) {
-          logger.atSevere().log("skipping corrupt meta-ref %s", metaRef.getName());
-          continue;
-        }
-        bu.addOp(
-            changeId,
-            new RecursiveApprovalCopier.PersistCopiedVotesOp(approvalsUtil, labelsCopiedListener));
-      }
-
-      BatchRefUpdate bru = bu.prepareRefUpdates();
-      if (bru != null) {
-        List<ReceiveCommand> cmds = bru.getCommands();
-        pendingRefUpdates.compute(
-            project,
-            (p, u) -> {
-              if (u == null) {
-                return new LinkedList<>(cmds);
-              }
-              u.addAll(cmds);
-              return u;
-            });
-        totalRefUpdates.addAndGet(cmds.size());
-      }
-
-      finishedCopyApprovalsTasks.addAndGet(slice.size());
-    }
-  }
-
-  private List<ListenableFuture<Void>> submitBatchRefUpdateTasks(boolean shouldLockLooseRefs) {
-    logger.atInfo().log("submitting batch ref-update tasks");
-    List<ListenableFuture<Void>> futures = new LinkedList<>();
-    for (Map.Entry<Project.NameKey, List<ReceiveCommand>> e : pendingRefUpdates.entrySet()) {
-      Project.NameKey project = e.getKey();
-      List<ReceiveCommand> updates = e.getValue();
-      futures.add(
-          executor.submit(
-              () -> {
-                executeRefUpdates(project, updates, shouldLockLooseRefs);
-                return null;
-              }));
-    }
-    return futures;
-  }
-
-  private void executeRefUpdates(
-      Project.NameKey project, List<ReceiveCommand> updates, boolean shouldLockLooseRefs)
-      throws RepositoryNotFoundException, IOException {
-    logger.atInfo().log(
-        "executing batch ref-update for project %s, size %d", project, updates.size());
-    try (Repository repository = repositoryManager.openRepository(project)) {
-      RefDatabase refdb = repository.getRefDatabase();
-      BatchRefUpdate bu;
-      if (refdb instanceof RefDirectory) {
-        bu = ((RefDirectory) refdb).newBatchUpdate(shouldLockLooseRefs);
-      } else {
-        bu = refdb.newBatchUpdate();
-      }
-      bu.addCommand(updates);
-      RefUpdateUtil.executeChecked(bu, repository);
-
-      finishedRefUpdates.addAndGet(updates.size());
-      logProgress();
-    }
-  }
-
-  private boolean isCorrupt(Project.NameKey project, Change.Id changeId) {
-    Change c = ChangeNotes.Factory.newChange(project, changeId);
-    try {
-      changeNotesFactory.createForBatchUpdate(c, true);
-      return false;
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
-      return true;
-    }
-  }
-
-  public void persist(Change change) throws UpdateException, RestApiException {
-    Project.NameKey project = change.getProject();
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs())) {
-      Change.Id changeId = change.getId();
-      bu.addOp(changeId, new PersistCopiedVotesOp(approvalsUtil, null));
-      bu.execute();
-    }
-  }
-
-  private void logProgress() {
-    logger.atInfo().log(
-        "copy-approvals tasks done: %d/%d, ref-update tasks done: %d/%d",
-        finishedCopyApprovalsTasks.get(),
-        totalCopyApprovalsTasks.get(),
-        finishedRefUpdates.get(),
-        totalRefUpdates.get());
-  }
-
-  private static class PersistCopiedVotesOp implements BatchUpdateOp {
-    private final ApprovalsUtil approvalsUtil;
-    private final Consumer<Change> listener;
-
-    PersistCopiedVotesOp(
-        ApprovalsUtil approvalsUtil, @Nullable Consumer<Change> labelsCopiedListener) {
-      this.approvalsUtil = approvalsUtil;
-      this.listener = labelsCopiedListener;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws IOException {
-      Change change = ctx.getChange();
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId(), change.getLastUpdatedOn());
-      approvalsUtil.persistCopiedApprovals(
-          ctx.getNotes(),
-          ctx.getNotes().getCurrentPatchSet(),
-          ctx.getRevWalk(),
-          ctx.getRepoView().getConfig(),
-          update);
-
-      boolean labelsCopied = update.hasCopiedApprovals();
-
-      if (labelsCopied && listener != null) {
-        listener.accept(change);
-      }
-
-      return labelsCopied;
-    }
-  }
-}
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/TimestampHandler.java b/java/com/google/gerrit/server/args4j/InstantHandler.java
similarity index 73%
rename from java/com/google/gerrit/server/args4j/TimestampHandler.java
rename to java/com/google/gerrit/server/args4j/InstantHandler.java
index eddfbcd..bfca0f6 100644
--- a/java/com/google/gerrit/server/args4j/TimestampHandler.java
+++ b/java/com/google/gerrit/server/args4j/InstantHandler.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 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.
@@ -16,11 +16,10 @@
 
 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 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;
@@ -28,14 +27,14 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
-public class TimestampHandler extends OptionHandler<Timestamp> {
+public class InstantHandler extends OptionHandler<Instant> {
   public static final String TIMESTAMP_FORMAT = "yyyyMMdd_HHmm";
 
   @Inject
-  public TimestampHandler(
+  public InstantHandler(
       @Assisted CmdLineParser parser,
       @Assisted OptionDef option,
-      @Assisted Setter<Timestamp> setter) {
+      @Assisted Setter<Instant> setter) {
     super(parser, option, setter);
   }
 
@@ -43,11 +42,12 @@
   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()));
+      setter.addValue(
+          DateTimeFormatter.ofPattern(TIMESTAMP_FORMAT)
+              .withZone(ZoneId.of("UTC"))
+              .parse(timestamp, Instant::from));
       return 1;
-    } catch (ParseException e) {
+    } catch (DateTimeParseException e) {
       throw new CmdLineException(
           owner,
           String.format("Invalid timestamp: %s; expected format: %s", timestamp, TIMESTAMP_FORMAT),
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/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..ec527ba 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -45,33 +45,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) {
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/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..0403408 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -548,7 +548,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 +581,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 {
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 42f4879..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,77 +49,15 @@
   }
 
   @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) {
+  public <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader) {
     return cacheMaximumWeight(def) == 0
         ? new PassthroughLoadingCache<>(loader)
-        : (backend.isLegacyBackend()
-            ? createLegacy(def).build(loader)
-            : CaffeinatedGuava.build(create(def), loader));
-  }
-
-  @SuppressWarnings("unchecked")
-  private <K, V> CacheBuilder<K, V> createLegacy(CacheDef<K, V> def) {
-    CacheBuilder<K, V> builder = newLegacyCacheBuilder();
-    builder.recordStats();
-    builder.maximumWeight(cacheMaximumWeight(def));
-
-    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);
-
-    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.refreshAfterWrite(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "cache",
-              def.configKey(),
-              "refreshAfterWrite",
-              toSeconds(refreshAfterWrite),
-              SECONDS),
-          SECONDS);
-    } else if (refreshAfterWrite != null) {
-      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
-    }
-
-    return builder;
+        : CaffeinatedGuava.build(create(def), loader);
   }
 
   private <K, V> Caffeine<K, V> create(CacheDef<K, V> def) {
@@ -187,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/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..5a71ced 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,6 +37,7 @@
             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())
@@ -68,6 +70,7 @@
             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())
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..436fe76 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.cache.serialize.entities;
 
+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
@@ -30,7 +32,8 @@
         SubmitRequirementExpression.create(proto.getExpression()),
         SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
         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(
@@ -40,6 +43,7 @@
         .setStatus(r.status().name())
         .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..29bd045 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -99,7 +99,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());
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 54ebf40..63e2c08 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -118,6 +118,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;
@@ -202,7 +206,7 @@
     return out;
   }
 
-  private Map<String, ActionInfo> toActionMap(
+  private ImmutableMap<String, ActionInfo> toActionMap(
       RevisionResource rsrc,
       List<ActionVisitor> visitors,
       ChangeInfo changeInfo,
@@ -222,6 +226,6 @@
       }
       out.put(d.getId(), actionInfo);
     }
-    return out;
+    return ImmutableMap.copyOf(out);
   }
 }
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 e0a72ac4..2efa027 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -34,15 +35,18 @@
   private final AbandonOp.Factory abandonOpFactory;
   private final ChangeCleanupConfig cfg;
   private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   @Inject
   BatchAbandon(
       AbandonOp.Factory abandonOpFactory,
       ChangeCleanupConfig cfg,
-      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore) {
+      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.abandonOpFactory = abandonOpFactory;
     this.cfg = cfg;
     this.accountPatchReviewStore = accountPatchReviewStore;
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   /**
@@ -64,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())) {
@@ -74,6 +78,9 @@
                   change.project().get(), project.get()));
         }
         u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
+        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 85482e4..6ef7f1e 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -313,6 +313,7 @@
 
   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"
@@ -364,7 +365,7 @@
    * <p>Should not be used in new code, as it doesn't result in a single atomic batch ref update for
    * code and NoteDb meta refs.
    *
-   * @param updateRef whether to update the ref during {@code updateRepo}.
+   * @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
    */
   @Deprecated
   public ChangeInserter setUpdateRef(boolean updateRef) {
@@ -497,15 +498,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();
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 328c5de..32e40eb 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -35,11 +35,13 @@
 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;
@@ -60,7 +62,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 +100,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;
@@ -255,7 +254,6 @@
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
-      ExperimentFeatures experimentFeatures,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
@@ -274,9 +272,7 @@
     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;
 
     logger.atFine().log("options = %s", options);
@@ -382,10 +378,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;
   }
 
@@ -556,8 +552,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 +599,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 +641,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();
@@ -772,18 +774,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 +795,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)
@@ -943,20 +946,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..0775647 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) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 1e40429..0116b01 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -195,7 +195,12 @@
       try {
         if (notify.shouldNotify()) {
           emailReviewers(
-              ctx.getProject(), currChange, mailMessage, ctx.getWhen(), notify, ctx.getRepoView());
+              ctx.getProject(),
+              currChange,
+              mailMessage,
+              Timestamp.from(ctx.getWhen()),
+              notify,
+              ctx.getRepoView());
         }
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
@@ -223,7 +228,7 @@
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
-    approvals = approvalsUtil.byChange(ctx.getNotes()).values();
+    approvals = ctx.getNotes().getApprovalsWithCopied().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
@@ -251,7 +256,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/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 3c7ea44..f94e592 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -66,7 +66,7 @@
         PatchSet patchSet,
         IdentifiedUser user,
         @Assisted("message") String message,
-        Timestamp timestamp,
+        Instant timestamp,
         List<? extends Comment> comments,
         @Assisted("patchSetComment") String patchSetComment,
         List<LabelVote> labels,
@@ -84,7 +84,7 @@
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final String message;
-  private final Timestamp timestamp;
+  private final Instant timestamp;
   private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
@@ -102,7 +102,7 @@
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted("message") String message,
-      @Assisted Timestamp timestamp,
+      @Assisted Instant timestamp,
       @Assisted List<? extends Comment> comments,
       @Nullable @Assisted("patchSetComment") String patchSetComment,
       @Assisted List<LabelVote> labels,
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index b729c11..44b4ded 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;
@@ -51,9 +52,11 @@
         // 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
@@ -66,7 +69,7 @@
       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);
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/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/IncludedInRefs.java b/java/com/google/gerrit/server/change/IncludedInRefs.java
index f069251..17a0c9d 100644
--- a/java/com/google/gerrit/server/change/IncludedInRefs.java
+++ b/java/com/google/gerrit/server/change/IncludedInRefs.java
@@ -16,11 +16,8 @@
 
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
@@ -28,7 +25,6 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -55,8 +51,7 @@
 
   public Map<String, Set<String>> apply(
       Project.NameKey project, Set<String> commits, Set<String> refNames)
-      throws ResourceConflictException, BadRequestException, IOException,
-          PermissionBackendException, ResourceNotFoundException, AuthException {
+      throws IOException, PermissionBackendException {
     try (Repository repo = repoManager.openRepository(project)) {
       Set<Ref> visibleRefs = getVisibleRefs(repo, refNames, project);
 
@@ -72,7 +67,7 @@
         }
       }
     }
-    return Collections.EMPTY_MAP;
+    return ImmutableMap.of();
   }
 
   private Set<Ref> getVisibleRefs(Repository repo, Set<String> refNames, Project.NameKey project)
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 5ce121b..69a84dd8 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -48,7 +48,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.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -95,53 +95,44 @@
     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();
+  }
 
+  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 +142,6 @@
     for (String label : toClear) {
       permitted.removeAll(label);
     }
-    return permitted.asMap();
   }
 
   private static boolean isOnlyZero(Collection<String> values) {
@@ -173,9 +163,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 +179,7 @@
           }
         }
       }
-      if (detailed) {
-        setLabelValues(type.get(), e.getValue());
-      }
+      setLabelValues(type.get(), e.getValue());
     }
     return labels;
   }
@@ -209,10 +196,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;
@@ -291,22 +278,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 +304,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 +353,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 +401,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 +428,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 +442,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 +453,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 +464,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..fc56e80 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -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;
@@ -184,6 +185,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;
@@ -298,10 +306,9 @@
       }
     }
 
-    // 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));
+      approvalsUtil.persistCopiedApprovals(
+          ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
     }
 
     return true;
@@ -368,7 +375,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..a0fa8e9 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -17,8 +17,10 @@
 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.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -94,6 +96,7 @@
   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;
@@ -191,6 +194,13 @@
     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,
@@ -241,6 +251,8 @@
       patchSetInserter.setWorkInProgress(true);
     }
 
+    patchSetInserter.setValidationOptions(validationOptions);
+
     if (postMessage) {
       patchSetInserter.setMessage(
           messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
@@ -392,7 +404,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(
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 547452e..719578f 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -29,7 +29,6 @@
 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;
@@ -196,7 +195,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<>();
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..c98fcaa 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;
@@ -378,9 +377,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 +399,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) {
@@ -648,7 +643,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..7d40f06 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;
@@ -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 8eeec62..96c863e 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -36,28 +36,39 @@
     if (req.applicabilityExpression().isPresent()) {
       info.applicabilityExpressionResult =
           submitRequirementExpressionToInfo(
-              req.applicabilityExpression().get(), result.applicabilityExpressionResult().get());
+              req.applicabilityExpression().get(),
+              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());
+              req.overrideExpression().get(),
+              result.overrideExpressionResult().get(),
+              /* hide= */ false);
     }
-    info.submittabilityExpressionResult =
-        submitRequirementExpressionToInfo(
-            req.submittabilityExpression(), result.submittabilityExpressionResult());
+    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;
   }
 
   private static SubmitRequirementExpressionInfo submitRequirementExpressionToInfo(
-      SubmitRequirementExpression expression, SubmitRequirementExpressionResult result) {
+      SubmitRequirementExpression expression,
+      SubmitRequirementExpressionResult result,
+      boolean hide) {
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
-    info.expression = expression.expressionString();
+    info.expression = hide ? null : expression.expressionString();
     info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
-    info.passingAtoms = result.passingAtoms();
-    info.failingAtoms = result.failingAtoms();
+    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/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/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/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..496808a 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -23,7 +23,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 +42,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();
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 75df0e8..6449155 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -16,7 +16,9 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
+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;
@@ -43,6 +45,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;
@@ -106,7 +109,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;
@@ -167,9 +169,8 @@
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.MailSoySauceProvider;
+import com.google.gerrit.server.mail.send.MailSoySauceModule;
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
-import com.google.gerrit.server.mail.send.MailTemplates;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -187,13 +188,16 @@
 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.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.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -224,7 +228,7 @@
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
-import com.google.template.soy.jbcsrc.api.SoySauce;
+import com.google.inject.multibindings.OptionalBinder;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
@@ -248,7 +252,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());
@@ -286,11 +289,13 @@
     install(new FileInfoJsonModule());
     install(ThreadLocalRequestContext.module());
     install(new ApprovalModule());
+    install(new MailSoySauceModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
@@ -327,7 +332,6 @@
 
     bind(ApprovalsUtil.class);
 
-    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(MailSoySauceProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(EnablePeerIPInReflogRecord.class)
@@ -337,6 +341,9 @@
     bind(PatchSetInfoFactory.class);
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
     bind(AccountControl.Factory.class);
+    OptionalBinder.newOptionalBinder(binder(), Ticker.class)
+        .setDefault()
+        .toInstance(Ticker.systemTicker());
 
     bind(UiActions.class);
 
@@ -346,6 +353,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);
@@ -381,13 +389,15 @@
     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);
     DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.bind(binder(), CommitValidationListener.class)
+        .to(SubmitRequirementExpressionsValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -430,6 +440,7 @@
     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);
     if (cfg.getBoolean("tracing", "exportPerformanceMetrics", false)) {
@@ -485,6 +496,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);
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/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index d71f83e..c2c0b05 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -36,7 +36,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 +78,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;
     }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index bc905c2..232aa6a 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -52,7 +52,7 @@
 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.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -139,7 +139,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 +187,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);
@@ -385,7 +385,7 @@
       return unmodifiedEdit.get();
     }
 
-    Timestamp nowTimestamp = TimeUtil.nowTs();
+    Instant nowTimestamp = TimeUtil.now();
     ObjectId newEditCommit =
         createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
 
@@ -407,14 +407,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 +502,7 @@
       RevCommit basePatchsetCommit,
       ObjectId tree,
       String commitMessage,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException {
     try (ObjectInserter objectInserter = repository.newObjectInserter()) {
       CommitBuilder builder = new CommitBuilder();
@@ -516,7 +517,7 @@
     }
   }
 
-  private PersonIdent getCommitterIdent(Timestamp commitTimestamp) {
+  private PersonIdent getCommitterIdent(Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
     return user.newCommitterIdent(commitTimestamp, tz);
   }
@@ -547,7 +548,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException;
   }
 
@@ -647,7 +648,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.updateEdit(
           notes.getProjectName(), repository, changeEdit, newEditCommitId, timestamp);
@@ -701,7 +702,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.createEdit(repository, notes, basePatchSet, newEditCommitId, timestamp);
     }
@@ -723,7 +724,7 @@
         ChangeNotes notes,
         PatchSet basePatchset,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       Change change = notes.getChange();
       String editRefName = getEditRefName(change, basePatchset);
@@ -750,7 +751,7 @@
         Repository repository,
         ChangeEdit changeEdit,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       String editRefName = changeEdit.getRefName();
       RevCommit currentEditCommit = changeEdit.getEditCommit();
@@ -769,7 +770,7 @@
         String refName,
         ObjectId currentObjectId,
         ObjectId targetObjectId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       RefUpdate ru = repository.updateRef(refName);
       ru.setExpectedOldObjectId(currentObjectId);
@@ -795,7 +796,7 @@
         PatchSet currentPatchSet,
         ObjectId currentEditCommit,
         ObjectId newEditCommitId,
-        Timestamp nowTimestamp)
+        Instant nowTimestamp)
         throws IOException {
       String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
       updateReferenceWithNameChange(
@@ -814,7 +815,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,7 +839,7 @@
       }
     }
 
-    private PersonIdent getRefLogIdent(Timestamp timestamp) {
+    private PersonIdent getRefLogIdent(Instant timestamp) {
       IdentifiedUser user = currentUser.get().asIdentifiedUser();
       return user.newRefLogIdent(timestamp, tz);
     }
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/ModificationTarget.java b/java/com/google/gerrit/server/edit/ModificationTarget.java
index 0de0149..86de812 100644
--- a/java/com/google/gerrit/server/edit/ModificationTarget.java
+++ b/java/com/google/gerrit/server/edit/ModificationTarget.java
@@ -52,21 +52,21 @@
   /** A specific patchset commit is the target of the modification. */
   class PatchsetCommit implements ModificationTarget {
 
-    private final PatchSet patchset;
+    private final PatchSet patchSet;
 
-    PatchsetCommit(PatchSet patchset) {
-      this.patchset = patchset;
+    PatchsetCommit(PatchSet patchSet) {
+      this.patchSet = patchSet;
     }
 
     @Override
     public void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit)
         throws InvalidChangeOperationException {
-      if (!isBasedOn(changeEdit, patchset)) {
+      if (!isBasedOn(changeEdit, patchSet)) {
         throw new InvalidChangeOperationException(
             String.format(
                 "Only the patch set %s on which the existing change edit is based may be modified "
                     + "(specified patch set: %s)",
-                changeEdit.getBasePatchSet().id(), patchset.id()));
+                changeEdit.getBasePatchSet().id(), patchSet.id()));
       }
     }
 
@@ -78,7 +78,7 @@
     @Override
     public void ensureNewEditMayBeBasedOnTarget(Change change)
         throws InvalidChangeOperationException {
-      PatchSet.Id patchSetId = patchset.id();
+      PatchSet.Id patchSetId = patchSet.id();
       PatchSet.Id currentPatchSetId = change.currentPatchSetId();
       if (!patchSetId.equals(currentPatchSetId)) {
         throw new InvalidChangeOperationException(
@@ -91,13 +91,13 @@
     @Override
     public RevCommit getCommit(Repository repository) throws IOException {
       try (RevWalk revWalk = new RevWalk(repository)) {
-        return revWalk.parseCommit(patchset.commitId());
+        return revWalk.parseCommit(patchSet.commitId());
       }
     }
 
     @Override
     public PatchSet getBasePatchset() {
-      return patchset;
+      return patchSet;
     }
   }
 
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 39ab041..9c0b92a 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -98,7 +98,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 8b19ecb..95f6d96 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -59,6 +59,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;
@@ -133,7 +134,7 @@
     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 =
@@ -166,7 +167,7 @@
 
   /** 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();
   }
 
@@ -408,7 +409,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) {
@@ -452,7 +453,7 @@
     p.number = patchSet.number();
     p.ref = patchSet.refName();
     p.uploader = asAccountAttribute(patchSet.uploader(), accountLoader);
-    p.createdOn = patchSet.createdOn().getTime() / 1000L;
+    p.createdOn = patchSet.createdOn().getEpochSecond();
     PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
@@ -473,7 +474,7 @@
 
       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();
@@ -558,7 +559,7 @@
     a.type = approval.labelId().get();
     a.value = Short.toString(approval.value());
     a.by = asAccountAttribute(approval.accountId(), accountLoader);
-    a.grantedOn = approval.granted().getTime() / 1000L;
+    a.grantedOn = approval.granted().getEpochSecond();
     a.oldValue = null;
 
     Optional<LabelType> lt = labelTypes.byLabel(approval.labelId());
@@ -569,7 +570,7 @@
   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(), accountLoader)
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/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 1486559..ffeb44b 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,30 +22,19 @@
   /** 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 UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
+
   public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
       "GerritBackendRequestFeature__remove_revision_etag";
 
-  /** 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}.
+   * 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_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";
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
+      "GerritBackendRequestFeature__compute_from_all_users_repository";
 
   /** 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/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..45f7ecb 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -36,7 +36,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;
@@ -111,7 +111,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..deaaff8 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.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 vote has been deleted from a change. */
@@ -59,7 +59,7 @@
       NotifyHandling notify,
       String message,
       AccountState remover,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -103,7 +103,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/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 242c11b..9cc754c 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -30,7 +30,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
 import java.util.List;
 import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -155,8 +155,7 @@
   }
 
   private PersonIdent createPersonIdent() {
-    Date now = new Date();
-    return currentUser.get().newCommitterIdent(now, tz);
+    return currentUser.get().newCommitterIdent(Instant.now(), tz);
   }
 
   private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
diff --git a/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
index 580c0b9..3424477 100644
--- a/java/com/google/gerrit/server/git/ChangeMessageModifier.java
+++ b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -42,10 +43,13 @@
    * @param original the commit of the change being submitted. <b>Note that its commit message may
    *     be different than newCommitMessage argument.</b>
    * @param mergeTip the current HEAD of the destination branch, which will be a parent of a new
-   *     commit being generated
+   *     commit being generated. mergeTip can be null if the destination branch does not yet exist.
    * @param destination the branch onto which the change is being submitted
    * @return a new not null commit message.
    */
   String onSubmit(
-      String newCommitMessage, RevCommit original, RevCommit mergeTip, BranchNameKey destination);
+      String newCommitMessage,
+      RevCommit original,
+      @Nullable RevCommit mergeTip,
+      BranchNameKey destination);
 }
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index 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..e52c45f 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -53,8 +53,8 @@
 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.Set;
@@ -146,7 +146,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 +174,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());
@@ -206,7 +206,7 @@
       String message,
       ChangeNotes notes,
       CurrentUser user,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       @Nullable ObjectId generatedChangeId)
@@ -255,7 +255,7 @@
       ChangeNotes notes,
       CurrentUser user,
       @Nullable ObjectId generatedChangeId,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       Repository git)
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index 6ae4d62..30330eb 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -136,7 +136,7 @@
       }
       b.append(s);
     }
-    logger.atInfo().log(b.toString());
+    logger.atInfo().log("%s", b);
   }
 
   private static void logGcConfiguration(
@@ -153,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) {
@@ -176,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/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 6b5fd2e..57d37fa 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -35,7 +35,7 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Map;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -263,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) {
@@ -295,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) {
@@ -318,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 c1333cb..d84ce7b 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -31,6 +31,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
@@ -630,7 +631,7 @@
    * @return new message
    */
   public String createCommitMessageOnSubmit(
-      RevCommit n, RevCommit mergeTip, ChangeNotes notes, PatchSet.Id id) {
+      RevCommit n, @Nullable RevCommit mergeTip, ChangeNotes notes, PatchSet.Id id) {
     return commitMessageGenerator.generate(
         n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, id));
   }
@@ -784,8 +785,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);
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 4985288..52a34d9 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -19,6 +19,7 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Ticker;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.server.CancellationMetrics;
@@ -240,6 +241,7 @@
   private Optional<Long> timeout = Optional.empty();
 
   private final long maxIntervalNanos;
+  private final Ticker ticker;
 
   /**
    * Create a new progress monitor for multiple sub-tasks.
@@ -250,10 +252,11 @@
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
+      Ticker ticker,
       @Assisted OutputStream out,
       @Assisted TaskKind taskKind,
       @Assisted String taskName) {
-    this(cancellationMetrics, out, taskKind, taskName, 500, MILLISECONDS);
+    this(cancellationMetrics, ticker, out, taskKind, taskName, 500, MILLISECONDS);
   }
 
   /**
@@ -267,12 +270,14 @@
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
+      Ticker ticker,
       @Assisted OutputStream out,
       @Assisted TaskKind taskKind,
       @Assisted String taskName,
       @Assisted long maxIntervalTime,
       @Assisted TimeUnit maxIntervalUnit) {
     this.cancellationMetrics = cancellationMetrics;
+    this.ticker = ticker;
     this.out = out;
     this.taskKind = taskKind;
     this.taskName = taskName;
@@ -304,7 +309,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)
+   * @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.
@@ -345,7 +350,7 @@
   /**
    * Wait for a non-final task managed by a {@link Future}, with no timeout.
    *
-   * @see #waitForNonFinalTask(Future, long, TimeUnit)
+   * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
    */
   public <T> T waitForNonFinalTask(Future<T> workerFuture) {
     try {
@@ -377,7 +382,7 @@
       long cancellationTimeoutTime,
       TimeUnit cancellationTimeoutUnit)
       throws TimeoutException {
-    long overallStart = System.nanoTime();
+    long overallStart = ticker.read();
     long cancellationNanos =
         cancellationTimeoutTime > 0
             ? NANOSECONDS.convert(cancellationTimeoutTime, cancellationTimeoutUnit)
@@ -393,16 +398,33 @@
     synchronized (this) {
       long left = maxIntervalNanos;
       while (!workerFuture.isDone() && !done) {
-        long start = System.nanoTime();
+        long start = ticker.read();
         try {
-          NANOSECONDS.timedWait(this, left);
+          // Conditions below gives better granularity for timeouts.
+          // Originally, code always used fixed interval:
+          // NANOSECONDS.timedWait(this, maxIntervalNanos);
+          // As a result, the actual check for timeouts happened only every maxIntervalNanos
+          // (default value 500ms); so even if timout was set to 1ms, the actual timeout was 500ms.
+          // This is not a big issue, however it made our tests for timeouts flaky. For example,
+          // some tests in the CancellationIT set timeout to 1ms and expect that server returns
+          // timeout. However, server often returned OK result, because a request takes less than
+          // 500ms.
+          if (deadlineExceeded || deadline == 0) {
+            // We want to set deadlineExceeded flag as earliest as possible. If it is already
+            // set - there is no reason to wait less than maxIntervalNanos
+            NANOSECONDS.timedWait(this, maxIntervalNanos);
+          } else if (start <= deadline) {
+            // if deadlineExceeded is not set, then we should wait until deadline, but no longer
+            // than maxIntervalNanos (because we want to report a progress every maxIntervalNanos).
+            NANOSECONDS.timedWait(this, Math.min(deadline - start + 1, maxIntervalNanos));
+          }
         } catch (InterruptedException e) {
           throw new UncheckedExecutionException(e);
         }
 
         // Send an update on every wakeup (manual or spurious), but only move
         // the spinner every maxInterval.
-        long now = System.nanoTime();
+        long now = ticker.read();
 
         if (deadline > 0 && now > deadline) {
           if (!deadlineExceeded) {
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/PluggableCommitMessageGenerator.java b/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java
index 804a218..1f5a330 100644
--- a/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java
+++ b/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
@@ -41,7 +42,10 @@
    * modify the message.
    */
   public String generate(
-      RevCommit original, RevCommit mergeTip, BranchNameKey dest, String originalMessage) {
+      RevCommit original,
+      @Nullable RevCommit mergeTip,
+      BranchNameKey dest,
+      String originalMessage) {
     requireNonNull(original.getRawBuffer());
     if (mergeTip != null) {
       requireNonNull(mergeTip.getRawBuffer());
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index ff5bcc2..e2f9abd 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -45,6 +45,7 @@
 import java.util.Collections;
 import java.util.List;
 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
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 8b59474..3032bfe 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -30,11 +30,10 @@
 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;
@@ -84,10 +83,6 @@
     }
   }
 
-  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;
@@ -152,6 +147,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);
@@ -259,7 +255,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;
             }
           });
@@ -444,6 +440,10 @@
     }
   }
 
+  private static void logUncaughtException(Thread t, Throwable e) {
+    logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
+  }
+
   /**
    * Runnable needing to know it was canceled. Note that cancel is called only in case the task is
    * not in progress already.
@@ -496,7 +496,7 @@
     private final Executor executor;
     private final int taskId;
     private final AtomicBoolean running;
-    private final Date startTime;
+    private final Instant startTime;
 
     Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
       this.runnable = runnable;
@@ -504,7 +504,7 @@
       this.executor = executor;
       this.taskId = taskId;
       this.running = new AtomicBoolean();
-      this.startTime = new Date();
+      this.startTime = Instant.now();
     }
 
     public int getTaskId() {
@@ -527,7 +527,7 @@
       return State.SLEEPING;
     }
 
-    public Date getStartTime() {
+    public Instant getStartTime() {
       return startTime;
     }
 
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/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index aa4e88e..dd5af2c 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -326,9 +326,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() + "'");
     }
 
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 9a59f46..f212384 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -747,14 +747,19 @@
       return;
     }
 
-    if (!magicCommands.isEmpty()) {
-      metrics.pushCount.increment("magic", project.getName(), getUpdateType(magicCommands));
-    }
-    if (!regularCommands.isEmpty()) {
-      metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
-    }
-
     try {
+      if (!magicCommands.isEmpty()) {
+        parseMagicBranch(Iterables.getLast(magicCommands));
+        // Using the submit option submits the created change(s) immediately without checking labels
+        // nor submit rules. Hence we shouldn't record such pushes as "magic" which implies that
+        // code review is being done.
+        String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
+        metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
+      }
+      if (!regularCommands.isEmpty()) {
+        metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
+      }
+
       if (!regularCommands.isEmpty()) {
         handleRegularCommands(regularCommands, progress);
         return;
@@ -763,7 +768,6 @@
       boolean first = true;
       for (ReceiveCommand cmd : magicCommands) {
         if (first) {
-          parseMagicBranch(cmd);
           first = false;
         } else {
           reject(cmd, "duplicate request");
@@ -777,7 +781,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 {
@@ -836,7 +840,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);
@@ -858,7 +862,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);
@@ -1005,7 +1009,8 @@
     addMessage(changeFormatter.changeUpdated(input));
   }
 
-  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()))) {
@@ -1021,7 +1026,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)) {
@@ -1426,6 +1431,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());
@@ -2228,7 +2239,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());
@@ -2244,7 +2256,7 @@
     }
   }
 
-  private void warnAboutMissingChangeId(List<CreateRequest> newChanges) {
+  private void warnAboutMissingChangeId(ImmutableList<CreateRequest> newChanges) {
     for (CreateRequest create : newChanges) {
       try {
         receivePack.getRevWalk().parseBody(create.commit);
@@ -2261,7 +2273,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");
@@ -2276,7 +2288,7 @@
       try {
         RevCommit start = setUpWalkForSelectingChanges();
         if (start == null) {
-          return Collections.emptyList();
+          return ImmutableList.of();
         }
 
         LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
@@ -2354,7 +2366,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) {
@@ -2387,7 +2399,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
@@ -2424,7 +2436,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;
@@ -2440,7 +2452,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) {
@@ -2465,13 +2477,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,
@@ -2479,7 +2491,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;
@@ -2499,11 +2511,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();
@@ -2517,10 +2529,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);
     }
   }
 
@@ -2823,7 +2835,7 @@
     }
   }
 
-  private void preparePatchSetsForReplace(List<CreateRequest> newChanges) {
+  private void preparePatchSetsForReplace(ImmutableList<CreateRequest> newChanges) {
     try (TraceTimer traceTimer =
         newTimer(
             "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -3337,7 +3349,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());
@@ -3450,7 +3464,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)) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index df1888b..e50482d 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -21,7 +21,7 @@
 
   @VisibleForTesting
   public static final String ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP =
-      "only users with Toogle-Wip-State permission can modify Work-in-Progress";
+      "only users with Toggle-Wip-State permission can modify Work-in-Progress";
 
   static final String COMMAND_REJECTION_MESSAGE_FOOTER =
       "Contact an administrator to fix the permissions";
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index a9ef70e..e7e0e8f 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -48,13 +48,13 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 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.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.change.ReviewerOp;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
@@ -345,9 +345,8 @@
       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));
+    approvalsUtil.persistCopiedApprovals(
+        ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -407,8 +406,7 @@
     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));
@@ -439,8 +437,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 +450,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;
         }
@@ -538,13 +531,13 @@
         emailSender.addReviewers(
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId))
                 .collect(toImmutableSet()));
         emailSender.addExtraCC(
             Streams.concat(
                     oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
                 .collect(toImmutableSet()));
         emailSender.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
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 c2a5047..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);
@@ -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 596efb5..6118157 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;
@@ -265,12 +266,6 @@
         "multiple Change-Id lines in message footer";
     private static final String INVALID_CHANGE_ID_MSG =
         "invalid Change-Id line format in message footer";
-    private static final String HTTP_INSTALL_HOOK =
-        "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\""
-            + " %stools/hooks/commit-msg ; chmod +x \"$f\"";
-    private static final String SSH_INSTALL_HOOK =
-        "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/";
-    private static final String CHANGE_ID_MISSING_INSTALL_HOOKS = "  %s\nor, for http(s):\n  %s";
 
     @VisibleForTesting
     public static final String CHANGE_ID_MISMATCH_MSG =
@@ -378,7 +373,10 @@
       // HTTP(S)
       Optional<String> webUrl = urlFormatter.getWebUrl();
 
-      String httpHook = String.format(HTTP_INSTALL_HOOK, webUrl.get());
+      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());
@@ -405,8 +403,9 @@
 
       String sshHook =
           String.format(
-              SSH_INSTALL_HOOK, sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
-      return String.format(CHANGE_ID_MISSING_INSTALL_HOOKS, sshHook, httpHook);
+              "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\nor, for http(s):\n  %s", sshHook, httpHook);
     }
   }
 
@@ -449,7 +448,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();
@@ -510,7 +509,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))) {
@@ -518,9 +517,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(),
@@ -647,10 +649,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");
@@ -681,11 +683,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");
@@ -714,11 +716,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");
@@ -786,9 +788,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);
       }
     }
   }
@@ -827,9 +827,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();
@@ -884,9 +882,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();
     }
@@ -977,6 +974,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..85d232e 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;
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/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..5a9b9e5 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -45,9 +45,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 +89,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 +97,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 +112,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()));
@@ -172,9 +172,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..3f7ef2c 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;
@@ -139,6 +139,9 @@
     return result.stream().map(AccountGroupByIdAudit.Builder::build).collect(toImmutableList());
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
     Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent());
     if (!authorId.isPresent()) {
@@ -166,7 +169,7 @@
     return Optional.of(
         new AutoValue_AuditLogReader_ParsedCommit(
             authorId.get(),
-            new Timestamp(c.getAuthorIdent().getWhen().getTime()),
+            c.getAuthorIdent().getWhen().toInstant(),
             ImmutableList.copyOf(addedMembers),
             ImmutableList.copyOf(removedMembers),
             ImmutableList.copyOf(addedSubgroups),
@@ -257,7 +260,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..71cc08c 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -35,8 +35,9 @@
 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.Date;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.regex.Pattern;
@@ -279,7 +280,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();
@@ -299,6 +300,9 @@
     return c;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
@@ -314,11 +318,11 @@
 
     // 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));
-    commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
-    commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
+            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
+    commit.setAuthor(new PersonIdent(commit.getAuthor(), Date.from(commitTimestamp)));
+    commit.setCommitter(new PersonIdent(commit.getCommitter(), Date.from(commitTimestamp)));
 
     InternalGroup updatedGroup = updateGroup(commitTimestamp);
 
@@ -346,7 +350,7 @@
     return Optional.empty();
   }
 
-  private InternalGroup updateGroup(Timestamp commitTimestamp)
+  private InternalGroup updateGroup(Instant commitTimestamp)
       throws IOException, ConfigInvalidException {
     Config config = updateGroupProperties();
 
@@ -358,7 +362,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 +457,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..c0c934b 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;
@@ -248,7 +248,7 @@
   }
 
   private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    return user.newCommitterIdent(ident);
   }
 
   /**
@@ -292,9 +292,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 +505,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/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/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index a729863..9ad7cdb 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 
+import com.google.common.base.Ticker;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
@@ -114,6 +115,9 @@
   @Override
   protected void configure() {
     factory(MultiProgressMonitor.Factory.class);
+    OptionalBinder.newOptionalBinder(binder(), Ticker.class)
+        .setDefault()
+        .toInstance(Ticker.systemTicker());
 
     bind(AccountIndexRewriter.class);
     bind(AccountIndexCollection.class);
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 0dd22ce..416b175 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -116,8 +116,9 @@
   public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
       exact("preferredemail_exact").build(a -> a.account().preferredEmail());
 
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> a.account().registeredOn());
+      timestamp("registered").build(a -> Timestamp.from(a.account().registeredOn()));
 
   public static final FieldDef<AccountState, String> USERNAME =
       exact("username").build(a -> a.userName().map(String::toLowerCase).orElse(""));
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index b1ff504..6cdc9ae 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -140,7 +140,7 @@
     try {
       futures = new SliceScheduler(index, ok).schedule();
     } catch (ProjectsCollectionFailure e) {
-      logger.atSevere().log(e.getMessage());
+      logger.atSevere().log("%s", e.getMessage());
       return Result.create(sw, false, 0, 0);
     }
 
@@ -181,7 +181,7 @@
       return reindexProject(
           indexer, project, 0, 1, ChangeNotes.Factory.scanChangeIds(repo), done, failed);
     } catch (IOException e) {
-      logger.atSevere().log(e.getMessage());
+      logger.atSevere().log("%s", e.getMessage());
       return null;
     }
   }
@@ -261,7 +261,7 @@
         this.failed.update(1);
       }
 
-      logger.atWarning().withCause(e).log(error);
+      logger.atWarning().withCause(e).log("%s", error);
       verboseWriter.println(error);
     }
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 4a2419b..ee272b7 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -33,6 +33,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -55,6 +56,7 @@
 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;
@@ -81,6 +83,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;
@@ -90,6 +93,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;
@@ -111,6 +115,12 @@
 
   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 =
@@ -150,19 +160,29 @@
   public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
       fullText("topic5").build(ChangeField::getTopic);
 
+  /** Topic, a short annotation on the branch. */
+  public static final FieldDef<ChangeData, String> PREFIX_TOPIC =
+      prefix("topic6").build(ChangeField::getTopic);
+
   /** Submission id assigned by MergeOp. */
   public static final FieldDef<ChangeData, String> SUBMISSIONID =
       exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
 
   /** Last update time since January 1, 1970. */
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
+      timestamp("updated2")
+          .stored()
+          .build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
 
   /** When this change was merged, time since January 1, 1970. */
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
       timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
           .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));
 
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
@@ -193,6 +213,11 @@
       fullText("hashtag2")
           .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
 
+  /** Hashtags as prefix field for in-string search. */
+  public static final FieldDef<ChangeData, Iterable<String>> PREFIX_HASHTAG =
+      prefix("hashtag3")
+          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
   /** Hashtags with original case. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
       storedOnly("_hashtag")
@@ -254,6 +279,14 @@
         .collect(toSet());
   }
 
+  /** Footers from the commit message of the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> FOOTER_NAME =
+      exact(ChangeQueryBuilder.FIELD_FOOTER_NAME).buildRepeatable(ChangeField::getFootersNames);
+
+  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);
@@ -412,14 +445,31 @@
       integer(ChangeQueryBuilder.FIELD_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)
+          .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
+
+  /**
+   * Determines if a change is submittable based on {@link
+   * com.google.gerrit.entities.SubmitRequirement}s.
+   */
+  public static final FieldDef<ChangeData, String> IS_SUBMITTABLE =
+      exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE)
+          .build(
+              cd ->
+                  // All submit requirements should be fulfilled
+                  cd.submitRequirementsIncludingLegacy().values().stream()
+                          .allMatch(SubmitRequirementResult::fulfilled)
+                      ? "1"
+                      : "0");
+
   @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;
   }
@@ -431,7 +481,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);
@@ -440,7 +490,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;
   }
@@ -450,8 +500,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(',');
@@ -493,7 +542,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);
     }
@@ -502,7 +551,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) {
@@ -546,7 +595,7 @@
             changeId.get(), v);
         continue;
       }
-      Timestamp timestamp = new Timestamp(l);
+      Instant timestamp = Instant.ofEpochMilli(l);
 
       b.put(reviewerState.get(), address, timestamp);
     }
@@ -612,47 +661,107 @@
   private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
+    Table<String, Short, Integer> voteCounts = HashBasedTable.create();
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
-        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        increment(voteCounts, a.label(), a.value());
         Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
-        allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
-        if (cd.change().getOwner().equals(a.accountId())) {
-          allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
-          allApprovals.addAll(
-              getMaxMinAnyLabels(
-                  a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
-        }
-        if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
-          allApprovals.add(
-              formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
-          allApprovals.addAll(
-              getMaxMinAnyLabels(
-                  a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
-        }
+
+        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        allApprovals.addAll(getMagicLabelFormats(a.label(), a.value(), labelType, a.accountId()));
+        allApprovals.addAll(getLabelOwnerFormats(a, cd, labelType));
+        allApprovals.addAll(getLabelNonUploaderFormats(a, cd, labelType));
         distinctApprovals.add(formatLabel(a.label(), a.value()));
-        distinctApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, null));
+        distinctApprovals.addAll(
+            getMagicLabelFormats(a.label(), a.value(), labelType, /* accountId= */ null));
       }
     }
     allApprovals.addAll(distinctApprovals);
+    allApprovals.addAll(getCountLabelFormats(voteCounts, cd));
     return allApprovals;
   }
 
-  private static List<String> getMaxMinAnyLabels(
+  private static void increment(Table<String, Short, Integer> table, String k1, short k2) {
+    if (!table.contains(k1, k2)) {
+      table.put(k1, k2, 1);
+    } else {
+      int val = table.get(k1, k2);
+      table.put(k1, k2, val + 1);
+    }
+  }
+
+  private static List<String> getCountLabelFormats(
+      Table<String, Short, Integer> voteCounts, ChangeData cd) {
+    List<String> allFormats = new ArrayList<>();
+    for (String label : voteCounts.rowMap().keySet()) {
+      Optional<LabelType> labelType = cd.getLabelTypes().byLabel(label);
+      Map<Short, Integer> row = voteCounts.row(label);
+      for (short vote : row.keySet()) {
+        int count = row.get(vote);
+        allFormats.addAll(getCountLabelFormats(labelType, label, vote, count));
+      }
+    }
+    return allFormats;
+  }
+
+  private static List<String> getCountLabelFormats(
+      Optional<LabelType> labelType, String label, short vote, int count) {
+    List<String> formats =
+        getMagicLabelFormats(label, vote, labelType, /* accountId= */ null, /* count= */ count);
+    formats.add(formatLabel(label, vote, count));
+    return formats;
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
       String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
+    return getMagicLabelFormats(label, labelVal, labelType, accountId, /* count= */ null);
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
+      String label,
+      short labelVal,
+      Optional<LabelType> labelType,
+      @Nullable Account.Id accountId,
+      @Nullable Integer count) {
     List<String> labels = new ArrayList<>();
     if (labelType.isPresent()) {
       if (labelVal == labelType.get().getMaxPositive()) {
-        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId, count));
       }
       if (labelVal == labelType.get().getMaxNegative()) {
-        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId, count));
       }
     }
-    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
+    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId, count));
     return labels;
   }
 
+  private static List<String> getLabelOwnerFormats(
+      PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
+    List<String> allFormats = new ArrayList<>();
+    if (cd.change().getOwner().equals(a.accountId())) {
+      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+      allFormats.addAll(
+          getMagicLabelFormats(
+              a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+    }
+    return allFormats;
+  }
+
+  private static List<String> getLabelNonUploaderFormats(
+      PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
+    List<String> allFormats = new ArrayList<>();
+    if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
+      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
+      allFormats.addAll(
+          getMagicLabelFormats(
+              a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
+    }
+    return allFormats;
+  }
+
   public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
@@ -727,25 +836,33 @@
                       decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
 
   public static String formatLabel(String label, int value) {
-    return formatLabel(label, value, null);
+    return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
+  }
+
+  public static String formatLabel(String label, int value, @Nullable Integer count) {
+    return formatLabel(label, value, /* accountId= */ null, count);
   }
 
   public static String formatLabel(String label, int value, Account.Id accountId) {
+    return formatLabel(label, value, accountId, /* count= */ null);
+  }
+
+  public static String formatLabel(
+      String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + (value >= 0 ? "+" : "")
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
-  public static String formatLabel(String label, String value) {
-    return formatLabel(label, value, null);
-  }
-
-  public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+  public static String formatLabel(
+      String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + "="
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
   private static String formatAccount(Account.Id accountId) {
@@ -761,6 +878,11 @@
   public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
       fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
 
+  /** Commit message of the current patch set. */
+  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE_EXACT =
+      exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT)
+          .build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
+
   /** Summary or inline comment. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
       fullText(ChangeQueryBuilder.FIELD_COMMENT)
@@ -1100,8 +1222,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
@@ -1127,6 +1259,48 @@
     return result;
   }
 
+  /**
+   * 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;
+  }
+
   /** Serialized submit requirements, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
       storedOnly("full_submit_requirements")
@@ -1144,6 +1318,7 @@
                     SubmitRequirementProtoConverter.INSTANCE.fromProto(
                         Protos.parseUnchecked(
                             SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+            .filter(sr -> !sr.isLegacy())
             .collect(
                 ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
   }
@@ -1227,4 +1402,46 @@
   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/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 437c1c4..8b75872 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -42,6 +42,7 @@
 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;
@@ -186,6 +187,7 @@
   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();
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 9339d62..0a06735 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -183,9 +183,45 @@
       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();
+
+  @Deprecated
+  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
+  static final Schema<ChangeData> V73 = schema(V72, false);
+
+  @Deprecated
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
+  static final Schema<ChangeData> V74 =
+      new Schema.Builder<ChangeData>().add(V73).add(ChangeField.IS_SUBMITTABLE).build();
+
+  /**
+   * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
+   * allow easier search for topics.
+   */
+  @Deprecated
+  static final Schema<ChangeData> V75 =
+      new Schema.Builder<ChangeData>()
+          .add(V74)
+          .add(ChangeField.PREFIX_HASHTAG)
+          .add(ChangeField.PREFIX_TOPIC)
+          .build();
+
+  /** Added new field {@link ChangeField#FOOTER_NAME}. */
+  @Deprecated
+  static final Schema<ChangeData> V76 =
+      new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
+
+  /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
+  static final Schema<ChangeData> V77 =
+      new Schema.Builder<ChangeData>().add(V76).add(ChangeField.COMMIT_MESSAGE_EXACT).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 1d9bbcd..339d7bb 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -101,7 +101,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(
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 49f6ff9..458f4a4 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,54 @@
   }
 
   @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())) {
+          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+          if (accountId != null && !ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
+            indexer.get().index(accountId);
+            break;
+          }
+        }
       }
       // 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 +141,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 +152,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 +172,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/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index df90c0d..af74514 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -47,8 +47,9 @@
       exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
 
   /** Timestamp indicating when this group was created. */
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
-      timestamp("created_on").build(InternalGroup::getCreatedOn);
+      timestamp("created_on").build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
 
   /** Group name. */
   public static final FieldDef<InternalGroup, String> NAME =
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/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 89b5b46..5cd0e98 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -165,6 +165,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();
 
@@ -375,6 +377,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/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..0fc89ba 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -301,7 +301,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 +313,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();
     }
@@ -378,8 +380,7 @@
       // 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/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 5c0132c..288ccf8 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -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,15 +54,15 @@
 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.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.james.mime4j.dom.field.FieldName;
@@ -72,6 +75,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(
@@ -86,7 +90,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 +100,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 +127,7 @@
     patchSetInfo = psi;
   }
 
-  public void setChangeMessage(String cm, Timestamp t) {
+  public void setChangeMessage(String cm, Instant t) {
     changeMessage = cm;
     timestamp = t;
   }
@@ -190,7 +194,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,6 +233,18 @@
     setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
   }
 
+  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. */
   @Nullable
   public String getChangeUrl() {
@@ -240,11 +256,11 @@
 
   public String getChangeMessageThreadId() {
     return "<gerrit."
-        + change.getCreatedOn().getTime()
+        + change.getCreatedOn().toEpochMilli()
         + "."
         + change.getKey().get()
         + "@"
-        + this.getGerritHost()
+        + getGerritHost()
         + ">";
   }
 
@@ -269,7 +285,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 +299,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 +307,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 +319,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. */
@@ -504,6 +523,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<>();
@@ -517,7 +539,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) {
@@ -569,7 +591,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");
@@ -586,16 +608,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;
@@ -605,6 +622,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);
@@ -631,7 +653,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..ce5438b 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -37,7 +37,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;
@@ -60,13 +59,16 @@
 
 /** 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);
   }
 
   private class FileCommentGroup {
+
     public String filename;
     public int patchSetId;
     public PatchFile fileData;
@@ -199,12 +201,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()) {
@@ -555,6 +552,6 @@
   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 2ab73a8..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 -> addWatcher(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/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
similarity index 88%
rename from java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
rename to java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index aade30f..8ee8fc2 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -14,12 +14,12 @@
 
 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;
-import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.template.soy.SoyFileSet;
@@ -27,13 +27,18 @@
 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;
 
-/** Configures Soy Sauce object for rendering email templates. */
+/**
+ * Configures and loads Soy Sauce object for rendering email templates.
+ *
+ * <p>It reloads templates each time when {@link #load()} is called.
+ */
 @Singleton
-public class MailSoySauceProvider implements Provider<SoySauce> {
+class MailSoySauceLoader {
 
   // Note: will fail to construct the tofu object if this array is empty.
   private static final String[] TEMPLATES = {
@@ -90,7 +95,7 @@
   private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
 
   @Inject
-  MailSoySauceProvider(
+  MailSoySauceLoader(
       SitePaths site,
       SoyAstCache cache,
       PluginSetContext<MailSoyTemplateProvider> templateProviders) {
@@ -99,8 +104,7 @@
     this.templateProviders = templateProviders;
   }
 
-  @Override
-  public SoySauce get() throws ProvisionException {
+  public SoySauce load() {
     SoyFileSet.Builder builder = SoyFileSet.builder();
     builder.setSoyAstCache(cache);
     for (String name : TEMPLATES) {
@@ -135,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
new file mode 100644
index 0000000..25b2ebd
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -0,0 +1,103 @@
+// 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;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+import javax.inject.Provider;
+
+/**
+ * Provides support for soy templates
+ *
+ * <p>Module loads templates with {@link MailSoySauceLoader} and caches compiled templates. The
+ * cache refreshes automatically, so Gerrit does not need to be restarted if templates are changed.
+ */
+public class MailSoySauceModule extends CacheModule {
+  static final String CACHE_NAME = "soy_sauce_compiled_templates";
+  private static final String SOY_LOADING_CACHE_KEY = "KEY";
+
+  @Override
+  protected void configure() {
+    // Cache stores only a single key-value pair (key is SOY_LOADING_CACHE_KEY). We are using
+    // cache only for it refresh/expire logic.
+    cache(CACHE_NAME, String.class, SoySauce.class)
+        // Cache refreshes a value only on the access (if refreshAfterWrite interval is
+        // passed). While the value is refreshed, cache returns old value.
+        // Adding expireAfterWrite interval prevents cache from returning very old template.
+        .refreshAfterWrite(Duration.ofSeconds(5))
+        .expireAfterWrite(Duration.ofMinutes(1))
+        .loader(SoySauceCacheLoader.class);
+    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(SoySauceProvider.class);
+  }
+
+  @Singleton
+  static class SoySauceProvider implements Provider<SoySauce> {
+    private final LoadingCache<String, SoySauce> templateCache;
+
+    @Inject
+    SoySauceProvider(@Named(CACHE_NAME) LoadingCache<String, SoySauce> templateCache) {
+      this.templateCache = templateCache;
+    }
+
+    @Override
+    public SoySauce get() {
+      try {
+        return templateCache.get(SOY_LOADING_CACHE_KEY);
+      } catch (ExecutionException e) {
+        throw new ProvisionException("Can't get SoySauce from the cache", e);
+      }
+    }
+  }
+
+  @Singleton
+  static class SoySauceCacheLoader extends CacheLoader<String, SoySauce> {
+    private final ListeningExecutorService executor;
+    private final MailSoySauceLoader loader;
+
+    @Inject
+    SoySauceCacheLoader(
+        @CacheRefreshExecutor ListeningExecutorService executor, MailSoySauceLoader loader) {
+      this.executor = executor;
+      this.loader = loader;
+    }
+
+    @Override
+    public SoySauce load(String key) throws Exception {
+      checkArgument(
+          SOY_LOADING_CACHE_KEY.equals(key),
+          "Cache can have only one element with a key '%s'",
+          SOY_LOADING_CACHE_KEY);
+      return loader.load();
+    }
+
+    @Override
+    public ListenableFuture<SoySauce> reload(String key, SoySauce soySauce) {
+      return executor.submit(() -> loader.load());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 91310ce..5b209ce 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -26,6 +26,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,11 +81,11 @@
   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) {
+  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);
     }
   }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 286f0c7..bfc1f5b 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());
@@ -392,7 +398,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));
   }
 
@@ -548,6 +554,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;
@@ -569,6 +577,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:
@@ -578,6 +587,7 @@
             ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
+            smtpBccRcptTo.add(addr);
             break;
         }
       }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 173b121..9299d74 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -30,6 +30,7 @@
 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;
@@ -111,13 +112,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 +126,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 +172,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<>();
 
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index d32e6fb..0a721cf 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;
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 6677490..7efda47 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.Date;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
@@ -45,7 +46,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;
@@ -55,14 +56,17 @@
   private ObjectId result;
   boolean rootOnly;
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNotes notes,
       CurrentUser user,
       PersonIdent serverIdent,
       ChangeNoteUtil noteUtil,
-      Date when) {
+      Instant when) {
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, when);
+    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
     this.notes = notes;
     this.change = notes.getChange();
     this.accountId = accountId(user);
@@ -72,6 +76,9 @@
     this.when = when;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   AbstractChangeUpdate(
       ChangeNoteUtil noteUtil,
       PersonIdent serverIdent,
@@ -80,12 +87,12 @@
       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");
     this.noteUtil = noteUtil;
-    this.serverIdent = new PersonIdent(serverIdent, when);
+    this.serverIdent = new PersonIdent(serverIdent, Date.from(when));
     this.notes = notes;
     this.change = change != null ? change : notes.getChange();
     this.accountId = accountId;
@@ -107,7 +114,7 @@
   }
 
   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 +144,7 @@
     return change;
   }
 
-  public Date getWhen() {
+  public Instant getWhen() {
     return when;
   }
 
@@ -206,6 +213,9 @@
    *     deleted.
    * @throws IOException if a lower-level error occurred.
    */
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
@@ -226,7 +236,7 @@
       return null; // Impl is a no-op.
     }
     cb.setAuthor(authorIdent);
-    cb.setCommitter(new PersonIdent(serverIdent, when));
+    cb.setCommitter(new PersonIdent(serverIdent, Date.from(when)));
     setParentCommit(cb, curr);
     if (cb.getTreeId() == null) {
       if (curr.equals(z)) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 57f6353..5d19205 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -31,9 +31,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 +60,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
@@ -101,7 +101,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 +115,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;
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 4c41a12..0f1d362c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -17,8 +17,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
 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 +31,40 @@
 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())
         .setPrettyPrinting()
         .create();
   }
@@ -47,6 +73,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
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 28ab711..e9d2f4c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.json.OutputFormat;
@@ -23,7 +25,9 @@
 import com.google.inject.Inject;
 import java.time.Instant;
 import java.util.Date;
+import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -31,29 +35,30 @@
 
 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");
+  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");
 
   static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
 
@@ -95,11 +100,15 @@
    * 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) {
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
+  public PersonIdent newAccountIdIdent(
+      Account.Id accountId, Instant when, PersonIdent serverIdent) {
     return new PersonIdent(
         getAccountIdAsUsername(accountId),
         getAccountIdAsEmailAddress(accountId),
-        when,
+        Date.from(when),
         serverIdent.getTimeZone());
   }
 
@@ -245,4 +254,203 @@
         new AttentionStatusInNoteDb(
             stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
   }
+
+  /**
+   * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link #FOOTER_LABEL} or
+   * {@link #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_ChangeNoteUtil_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 #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 #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>
+   */
+  public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
+      throws ConfigInvalidException {
+    try {
+      // Copied approvals can't be explicitly removed. They are removed the same way as non-copied
+      // approvals.
+      checkFooter(!labelLine.startsWith("-"), FOOTER_COPIED_LABEL, labelLine);
+      ParsedPatchSetApproval.Builder rawPatchSetApproval =
+          ParsedPatchSetApproval.builder().footerLine(labelLine).isRemoval(false);
+
+      int tagStart = labelLine.indexOf(":\"");
+      int uuidStart = labelLine.indexOf(", ");
+
+      // 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(0, 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/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 42fe4e2..3095cd2 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -31,7 +31,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;
@@ -66,7 +65,7 @@
 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;
@@ -275,8 +274,8 @@
 
     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)
@@ -286,7 +285,7 @@
               .forEach(n -> m.put(n.getProjectName(), n));
         }
       }
-      return ImmutableListMultimap.copyOf(m);
+      return m.build();
     }
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
@@ -444,13 +443,7 @@
     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.
-   */
+  /** Gets all approvals, including copied approvals. */
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
     if (approvalsWithCopied == null) {
       approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
@@ -493,11 +486,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();
   }
 
@@ -565,7 +566,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());
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index c554ca5..40bf6e5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -61,7 +61,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(2)
+            .version(4)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 8aa29cc..1d8ec82 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -40,11 +40,13 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.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,12 +72,14 @@
 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.notedb.ChangeNoteUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
@@ -83,6 +87,7 @@
 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 +100,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 +110,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 +150,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 +176,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,7 +198,7 @@
   // 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;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -312,10 +346,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 +438,7 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    Timestamp commitTimestamp = getCommitTimestamp(commit);
+    Instant commitTimestamp = getCommitTimestamp(commit);
 
     createdOn = commitTimestamp;
     parseTag(commit);
@@ -387,7 +492,7 @@
 
     parseSubmission(commit, commitTimestamp);
 
-    if (lastUpdatedOn == null || commitTimestamp.after(lastUpdatedOn)) {
+    if (lastUpdatedOn == null || commitTimestamp.isAfter(lastUpdatedOn)) {
       lastUpdatedOn = commitTimestamp;
     }
 
@@ -449,7 +554,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) {
@@ -532,7 +637,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 +710,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 +732,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) {
@@ -737,7 +842,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 +888,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,69 +926,46 @@
     }
   }
 
-  // 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 = ChangeNoteUtil.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 tagStart = line.indexOf(":\"");
-    // UUID introduced in https://gerrit-review.googlesource.com/c/gerrit/+/324937
-    // Only parsed for backward compatibility
-    // Footer has the following format in this case:
-    // Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
-    int uuidStart = line.indexOf(", ");
-    // Wired tag that contains uuid delimiter. The uuid is actually not present.
-    if (tagStart != -1 && uuidStart > tagStart) {
-      uuidStart = -1;
-    }
-    int identitiesStart = line.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
-    // The first account is the accountId, and second (if applicable) is the realAccountId.
-    try {
-      labelVoteStr = line.substring(0, uuidStart != -1 ? uuidStart : identitiesStart);
-    } catch (StringIndexOutOfBoundsException ex) {
-      throw new ConfigInvalidException(ex.getMessage(), ex);
-    }
-    String[] identities =
-        line.substring(identitiesStart + 1, tagStart == -1 ? line.length() : tagStart).split(",");
-    PersonIdent ident = RawParseUtils.parsePersonIdent(identities[0]);
-    checkFooter(ident != null, FOOTER_COPIED_LABEL, line);
-    accountId = parseIdent(ident);
+    checkFooter(accountIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
+    Account.Id accountId = parseIdent(accountIdent);
 
-    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;
     try {
-      l = LabelVote.parseWithEquals(labelVoteStr);
+      l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
     } 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())))
+            .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
             .value(l.value())
             .granted(ts)
-            .tag(Optional.ofNullable(tag))
+            .tag(parsedPatchSetApproval.tag())
             .copied(true);
     if (realAccountId != null) {
       psa.realAccountId(realAccountId);
@@ -873,69 +975,46 @@
   }
 
   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 = ChangeNoteUtil.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 ChangeNoteUtil#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;
-    // UUID introduced in https://gerrit-review.googlesource.com/c/gerrit/+/324937
-    // Only parsed for backward compatibility
-    // Footer has the following format in this case: Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
-    int uuidStart = line.indexOf(", ");
-    int reviewerStart = line.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
-    if (uuidStart != -1) {
-      labelVoteStr = line.substring(0, uuidStart);
-    } else if (reviewerStart != -1) {
-      labelVoteStr = line.substring(0, reviewerStart);
-    } else {
-      labelVoteStr = line;
-    }
-    if (reviewerStart > 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.
-      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(reviewerStart + 1));
-      checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = parseIdent(ident);
-    } else {
-      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));
@@ -947,26 +1026,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;
     }
@@ -975,7 +1052,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)) {
@@ -985,6 +1064,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;
 
@@ -1003,7 +1106,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;
         }
@@ -1040,7 +1143,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) {
@@ -1053,7 +1156,7 @@
     }
   }
 
-  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+  private void parseReviewerByEmail(Instant ts, ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     Address adr;
     try {
@@ -1167,15 +1270,18 @@
    * @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());
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
+  private Instant getCommitTimestamp(ChangeNotesCommit commit) {
+    return commit.getCommitterIdent().getWhen().toInstant();
   }
 
   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();
       }
@@ -1183,10 +1289,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();
       }
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 cc9b193..8f352cb 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -52,6 +52,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Table;
 import com.google.common.collect.Table.Cell;
 import com.google.common.collect.TreeBasedTable;
@@ -63,6 +64,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 +76,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 +84,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 +118,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 +139,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 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,6 +179,7 @@
   private RobotCommentUpdate robotCommentUpdate;
   private DeleteCommentRewriter deleteCommentRewriter;
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
+  private List<SubmitRequirementResult> submitRequirementResults;
 
   @AssistedInject
   private ChangeUpdate(
@@ -179,9 +190,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 +202,7 @@
         robotCommentUpdateFactory,
         deleteCommentRewriterFactory,
         serviceUserClassifier,
+        patchSetApprovalUuidGenerator,
         notes,
         user,
         when,
@@ -214,9 +227,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 +239,7 @@
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.serviceUserClassifier = serviceUserClassifier;
+    this.patchSetApprovalUuidGenerator = patchSetApprovalUuidGenerator;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -286,10 +301,6 @@
     copiedApprovals.add(copiedPatchSetApproval);
   }
 
-  public boolean hasCopiedApprovals() {
-    return !copiedApprovals.isEmpty();
-  }
-
   public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toString();
@@ -323,10 +334,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) {
@@ -512,7 +526,7 @@
   /** Returns the tree id for the updated tree */
   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);
@@ -522,8 +536,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);
@@ -634,12 +663,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");
 
@@ -648,7 +677,7 @@
       msg.append("\n\n");
     }
 
-    addPatchSetFooter(msg, ps);
+    addPatchSetFooter(msg, patchSetId);
 
     if (currentPatchSet) {
       addFooter(msg, FOOTER_CURRENT, Boolean.TRUE);
@@ -722,7 +751,7 @@
     }
 
     for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
-      addLabelFooter(msg, c);
+      addLabelFooter(msg, c, patchSetId);
     }
     for (PatchSetApproval patchSetApproval : copiedApprovals) {
       addCopiedLabelFooter(msg, patchSetApproval);
@@ -791,7 +820,10 @@
       }
     }
 
-    updateAttentionSet(msg);
+    boolean hasAttentionSeUpdates = updateAttentionSet(msg);
+    if (isEmptyWithoutAttentionSet() && !hasAttentionSeUpdates) {
+      return NO_OP_UPDATE;
+    }
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -806,17 +838,25 @@
     return cb;
   }
 
-  private void addLabelFooter(StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c) {
+  private void addLabelFooter(
+      StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c, PatchSet.Id patchSetId) {
     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();
+      msg.append(LabelVote.create(label, c.getValue().get()).formatWithEquals());
+      msg.append(", ");
+      msg.append(patchSetApprovalUuidGenerator.get(patchSetId, reviewerId, label, value, when));
     }
-    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');
   }
@@ -830,6 +870,11 @@
     // 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);
 
@@ -844,6 +889,7 @@
     if (patchSetApproval.tag().isPresent()) {
       msg.append(":\"" + sanitizeFooter(patchSetApproval.tag().get()) + "\"");
     }
+
     msg.append('\n');
   }
 
@@ -911,8 +957,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<>();
     }
@@ -938,6 +987,8 @@
 
     removeInactiveUsersFromAttentionSet(currentReviewers);
 
+    boolean hasUpdates = false;
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -972,7 +1023,9 @@
       }
 
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      hasUpdates = true;
     }
+    return hasUpdates;
   }
 
   private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
@@ -1025,8 +1078,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(')');
     }
@@ -1040,6 +1093,10 @@
 
   @Override
   public boolean isEmpty() {
+    return isEmptyWithoutAttentionSet() && plannedAttentionSetUpdates == null;
+  }
+
+  private boolean isEmptyWithoutAttentionSet() {
     return commitSubject == null
         && approvals.isEmpty()
         && copiedApprovals.isEmpty()
@@ -1052,7 +1109,6 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && plannedAttentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 338b984..534da0d 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
 
 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 +70,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 +111,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 +126,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 +229,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 +267,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)) {
@@ -474,7 +480,7 @@
           }
           detailedVerificationStatus.append("Commit author:\n");
           detailedVerificationStatus.append(fixedAuthorIdent.toString());
-          logger.atWarning().log(detailedVerificationStatus.toString());
+          logger.atWarning().log("%s", detailedVerificationStatus);
         }
       }
       boolean needsFix =
@@ -571,6 +577,9 @@
     }
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
     return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
         && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
@@ -687,15 +696,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 +776,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 =
@@ -943,7 +953,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 +1187,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()
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..28436db 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -141,7 +141,7 @@
   }
 
   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 4988406..5d8f57f 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;
@@ -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
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index e817fe6..b52671b 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -383,7 +383,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);
     }
@@ -470,4 +470,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/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 d53b2ca..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;
@@ -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..edf5bd3 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -28,9 +28,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,14 +57,14 @@
         @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<>();
@@ -77,7 +77,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);
   }
 
@@ -89,7 +89,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);
   }
 
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..a815f57 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -32,10 +32,14 @@
 
   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);
 
   @Override
   public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
@@ -46,13 +50,19 @@
     if (r.legacy().isPresent()) {
       builder.setLegacy(r.legacy().get());
     }
+    if (r.forced().isPresent()) {
+      builder.setForced(r.forced().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 +81,20 @@
     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_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 9a84398..f29d1c1 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));
     }
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/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/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 572d73d..2385a70 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(
@@ -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 b4d8c04..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;
@@ -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..3e4e72d 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;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 242c1a4..31fe77a 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.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/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 9d5dc2d..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;
 
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..a464235 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.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;
 
@@ -47,6 +47,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();
   }
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..22ec328 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;
 
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 44af22e..2b856fb 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;
 
@@ -60,7 +60,6 @@
 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;
@@ -156,16 +155,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;
@@ -294,10 +283,10 @@
               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));
     }
@@ -386,6 +375,16 @@
         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();
+    }
   }
 
   /**
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/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 37c773a..8d432c8 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -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. */
@@ -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:
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/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 1b528d7..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(repo, 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,70 +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(Repository repo, 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(repo, 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..506d292
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -0,0 +1,145 @@
+// 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.common.collect.ImmutableMap.toImmutableMap;
+
+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.Objects;
+import java.util.Set;
+import java.util.function.Function;
+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) {
+      changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName);
+    } else if (searchingChangeCache != null) {
+      changeDatas = searchingChangeCache.getChangeData(projectName);
+    } else {
+      changeDatas =
+          scanRepoForChangeDatas(changeNotesFactory, changeDataFactory, repository, projectName);
+    }
+
+    return changeDatas
+        .filter(cd -> changes.contains(cd.getId()))
+        .filter(
+            cd -> {
+              try {
+                return forProject.change(cd).test(ChangePermission.READ);
+              } catch (PermissionBackendException e) {
+                throw new StorageException(e);
+              }
+            })
+        .collect(toImmutableMap(ChangeData::getId, Function.identity()));
+  }
+
+  /** 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());
+                    } else {
+                      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/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 27c6793..fea2827 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;
 
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 1203049..e4fa1c4 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -165,9 +165,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;
     }
   }
@@ -347,7 +346,6 @@
   }
 
   private class ForProjectImpl extends ForProject {
-    private DefaultRefFilter refFilter;
     private String resourcePath;
 
     @Override
@@ -416,10 +414,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..478ba5c 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -271,13 +271,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 8a68a99..0000000
--- a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
+++ /dev/null
@@ -1,168 +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<>();
-    if (!projectState.statePermitsRead()) {
-      return;
-    }
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      for (ChangeData cd : changeCache.getChangeData(project)) {
-        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<>();
-    if (!projectState.statePermitsRead()) {
-      return;
-    }
-    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;
-    }
-
-    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..e119bf1 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -15,12 +15,12 @@
 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;
@@ -31,7 +31,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 +41,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 +78,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 +135,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;
       }
@@ -294,10 +290,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 +300,7 @@
                 throw new IllegalArgumentException(
                     "Cannot convert jar entry " + jarEntry + " to a resource", e);
               }
-            }));
+            });
   }
 
   @Override
@@ -333,4 +328,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 plugin returning classes and resources.
@@ -58,8 +58,8 @@
         }
 
         @Override
-        public Enumeration<PluginEntry> entries() {
-          return Collections.emptyEnumeration();
+        public Stream<PluginEntry> entries() {
+          return Stream.empty();
         }
       };
 
@@ -122,5 +122,5 @@
    *
    * @return the enumeration of all resources found
    */
-  Enumeration<PluginEntry> entries();
+  Stream<PluginEntry> entries();
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginResource.java b/java/com/google/gerrit/server/plugins/PluginResource.java
index e7ebd56..fb69233 100644
--- a/java/com/google/gerrit/server/plugins/PluginResource.java
+++ b/java/com/google/gerrit/server/plugins/PluginResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class PluginResource implements RestResource {
-  public static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND =
-      new TypeLiteral<RestView<PluginResource>>() {};
+  public static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND = new TypeLiteral<>() {};
 
   private final Plugin plugin;
   private final String name;
diff --git a/java/com/google/gerrit/server/plugins/PluginScannerThread.java b/java/com/google/gerrit/server/plugins/PluginScannerThread.java
index 705e3c0..1c2c836 100644
--- a/java/com/google/gerrit/server/plugins/PluginScannerThread.java
+++ b/java/com/google/gerrit/server/plugins/PluginScannerThread.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.plugins;
 
+import com.google.common.util.concurrent.Uninterruptibles;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
@@ -45,10 +46,6 @@
 
   void end() {
     done.countDown();
-    try {
-      join();
-    } catch (InterruptedException e) {
-      // Ignored
-    }
+    Uninterruptibles.joinUninterruptibly(this);
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index 639b278..60dff84 100644
--- a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -49,6 +49,7 @@
     bind(String.class)
         .annotatedWith(PluginCanonicalWebUrl.class)
         .toInstance(plugin.getPluginCanonicalWebUrl());
+    bind(Plugin.class).toInstance(plugin);
 
     install(
         new LifecycleModule() {
diff --git a/java/com/google/gerrit/server/plugins/TestServerPlugin.java b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
index 3751c3f..cd5d5e3 100644
--- a/java/com/google/gerrit/server/plugins/TestServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
@@ -28,7 +28,7 @@
       String name,
       String pluginCanonicalWebUrl,
       PluginUser user,
-      ClassLoader classloader,
+      ClassLoader classLoader,
       String sysName,
       String httpName,
       String sshName,
@@ -42,10 +42,10 @@
         null,
         null,
         dataDir,
-        classloader,
+        classLoader,
         null,
         GerritRuntime.DAEMON);
-    this.classLoader = classloader;
+    this.classLoader = classLoader;
     this.sysName = sysName;
     this.httpName = httpName;
     this.sshName = sshName;
diff --git a/java/com/google/gerrit/server/project/BranchResource.java b/java/com/google/gerrit/server/project/BranchResource.java
index a8936ac..fb1fa57 100644
--- a/java/com/google/gerrit/server/project/BranchResource.java
+++ b/java/com/google/gerrit/server/project/BranchResource.java
@@ -18,19 +18,20 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 
 public class BranchResource extends RefResource {
-  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND =
-      new TypeLiteral<RestView<BranchResource>>() {};
+  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND = new TypeLiteral<>() {};
 
   private final String refName;
-  private final String revision;
+  private final Optional<String> revision;
 
   public BranchResource(ProjectState projectState, CurrentUser user, Ref ref) {
     super(projectState, user);
     this.refName = ref.getName();
-    this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
+    this.revision = Optional.ofNullable(ref.getObjectId()).map(ObjectId::name);
   }
 
   public BranchNameKey getBranchKey() {
@@ -43,7 +44,7 @@
   }
 
   @Override
-  public String getRevision() {
+  public Optional<String> getRevision() {
     return revision;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ChildProjectResource.java b/java/com/google/gerrit/server/project/ChildProjectResource.java
index 4b641ca..854f876 100644
--- a/java/com/google/gerrit/server/project/ChildProjectResource.java
+++ b/java/com/google/gerrit/server/project/ChildProjectResource.java
@@ -21,7 +21,7 @@
 
 public class ChildProjectResource implements RestResource {
   public static final TypeLiteral<RestView<ChildProjectResource>> CHILD_PROJECT_KIND =
-      new TypeLiteral<RestView<ChildProjectResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ProjectResource parent;
   private final ProjectState child;
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 1b9dc37..c2ac68a 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
@@ -45,7 +44,8 @@
 
   private List<CommentLinkInfo> parseConfig(Config cfg) {
     Set<String> subsections = cfg.getSubsections(ProjectConfig.COMMENTLINK);
-    List<CommentLinkInfo> cls = Lists.newArrayListWithCapacity(subsections.size());
+    ImmutableList.Builder<CommentLinkInfo> cls =
+        ImmutableList.builderWithExpectedSize(subsections.size());
     for (String name : subsections) {
       try {
         StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
@@ -58,7 +58,7 @@
         logger.atWarning().log("invalid commentlink: %s", e.getMessage());
       }
     }
-    return ImmutableList.copyOf(cls);
+    return cls.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/project/CommitResource.java b/java/com/google/gerrit/server/project/CommitResource.java
index f71c7fe..ffd498d 100644
--- a/java/com/google/gerrit/server/project/CommitResource.java
+++ b/java/com/google/gerrit/server/project/CommitResource.java
@@ -20,8 +20,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class CommitResource implements RestResource {
-  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND =
-      new TypeLiteral<RestView<CommitResource>>() {};
+  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND = new TypeLiteral<>() {};
 
   private final ProjectResource project;
   private final RevCommit commit;
diff --git a/java/com/google/gerrit/server/project/DashboardResource.java b/java/com/google/gerrit/server/project/DashboardResource.java
index 54f958a..c918551 100644
--- a/java/com/google/gerrit/server/project/DashboardResource.java
+++ b/java/com/google/gerrit/server/project/DashboardResource.java
@@ -22,7 +22,7 @@
 
 public class DashboardResource implements RestResource {
   public static final TypeLiteral<RestView<DashboardResource>> DASHBOARD_KIND =
-      new TypeLiteral<RestView<DashboardResource>>() {};
+      new TypeLiteral<>() {};
 
   public static DashboardResource projectDefault(ProjectState projectState, CurrentUser user) {
     return new DashboardResource(projectState, user, null, null, null, true);
diff --git a/java/com/google/gerrit/server/project/FileResource.java b/java/com/google/gerrit/server/project/FileResource.java
index e8926dc..f02522a 100644
--- a/java/com/google/gerrit/server/project/FileResource.java
+++ b/java/com/google/gerrit/server/project/FileResource.java
@@ -33,8 +33,7 @@
  * <p>This is in the project package because it is accessed through the project/branch/file path.
  */
 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<>() {};
 
   public static FileResource create(
       GitRepositoryManager repoManager, ProjectState projectState, ObjectId rev, String path)
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 63c9d22..71ea12b 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -25,6 +25,7 @@
   public static LabelDefinitionInfo format(Project.NameKey projectName, LabelType labelType) {
     LabelDefinitionInfo label = new LabelDefinitionInfo();
     label.name = labelType.getName();
+    label.description = labelType.getDescription().orElse(null);
     label.projectName = projectName.get();
     label.function = labelType.getFunction().getFunctionName();
     label.values =
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
index 2df9ff1..fcddc61 100644
--- a/java/com/google/gerrit/server/project/LabelResource.java
+++ b/java/com/google/gerrit/server/project/LabelResource.java
@@ -20,8 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 public class LabelResource implements RestResource {
-  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND =
-      new TypeLiteral<RestView<LabelResource>>() {};
+  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND = new TypeLiteral<>() {};
 
   private final ProjectResource project;
   private final LabelType labelType;
diff --git a/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java
new file mode 100644
index 0000000..7e6d93e
--- /dev/null
+++ b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java
@@ -0,0 +1,53 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.ImplementedBy;
+
+/**
+ * Extension point that allows to modify the {@link SubmitRequirementResult} when it is stored
+ * NoteDB.
+ *
+ * <p>The submit requirements are only stored for the closed (merged and abandoned) changes and the
+ * modifier only affects what {@link SubmitRequirementResult} will be stored in NoteDB.
+ *
+ * <p>It has no impact on open changes or evaluations on merge, i.e. does not affect the
+ * submittability of the change (never blocks the ready change from submission or allows bypassing
+ * unsatisfied submit requirement).
+ *
+ * <p>The extension point only applies to non-legacy submit requirements (including non-applicable,
+ * since they are stored too) and does not affect submit rule results.
+ */
+@ExtensionPoint
+@ImplementedBy(OnStoreSubmitRequirementResultModifierImpl.class)
+public interface OnStoreSubmitRequirementResultModifier {
+
+  /**
+   * Evaluate a single {@link SubmitRequirement} using the change data, modifying the original
+   * {@code SubmitRequirementResult}, if needed.
+   *
+   * <p>Only affects how the submit requirement is stored in NoteDb for closed changes.
+   */
+  SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult submitRequirementResult,
+      ChangeData changeData,
+      ChangeContext ctx);
+}
diff --git a/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java
new file mode 100644
index 0000000..39a717f
--- /dev/null
+++ b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java
@@ -0,0 +1,35 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+
+/**
+ * Default implementation of {@link OnStoreSubmitRequirementResultModifier} that does not
+ * re-evaluate {@link SubmitRequirementResult}.
+ */
+public class OnStoreSubmitRequirementResultModifierImpl
+    implements OnStoreSubmitRequirementResultModifier {
+  @Override
+  public SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult result,
+      ChangeData cd,
+      ChangeContext ctx) {
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 0f6810f..67c031e 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -130,7 +130,7 @@
             .keySerializer(new ProtobufSerializer<>(Cache.ProjectCacheKeyProto.parser()))
             .valueSerializer(PersistedProjectConfigSerializer.INSTANCE)
             .diskLimit(1 << 30) // 1 GiB
-            .version(2)
+            .version(4)
             .maximumWeight(0);
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 332aba7..8794f66 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -54,15 +55,15 @@
               () -> {
                 for (Project.NameKey name : cache.all()) {
                   pool.execute(
-                      () ->
-                          cache
-                              .get(name)
-                              .orElseThrow(
-                                  () ->
-                                      new IllegalStateException(
-                                          "race while traversing projects. got "
-                                              + name
-                                              + " when loading all projects, but can't load it now")));
+                      () -> {
+                        Optional<ProjectState> project = cache.get(name);
+                        if (!project.isPresent()) {
+                          throw new IllegalStateException(
+                              "race while traversing projects. got "
+                                  + name
+                                  + " when loading all projects, but can't load it now");
+                        }
+                      });
                 }
                 pool.shutdown();
                 try {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index c101ddf..d816d84 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -23,11 +23,13 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -104,6 +106,7 @@
 
   public static final String COMMENTLINK = "commentlink";
   public static final String LABEL = "label";
+  public static final String KEY_LABEL_DESCRIPTION = "description";
   public static final String KEY_FUNCTION = "function";
   public static final String KEY_DEFAULT_VALUE = "defaultValue";
   public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
@@ -130,6 +133,13 @@
   public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
   public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideIf";
   public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
+  public static final ImmutableSet<String> SR_KEYS =
+      ImmutableSet.of(
+          KEY_SR_DESCRIPTION,
+          KEY_SR_APPLICABILITY_EXPRESSION,
+          KEY_SR_SUBMITTABILITY_EXPRESSION,
+          KEY_SR_OVERRIDE_EXPRESSION,
+          KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
 
   public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
@@ -512,12 +522,6 @@
     return sort(contributorAgreements.values());
   }
 
-  public void remove(ContributorAgreement section) {
-    if (section != null) {
-      accessSections.remove(section.getName());
-    }
-  }
-
   public void replace(ContributorAgreement section) {
     ContributorAgreement.Builder ca = section.toBuilder();
     ca.setAutoVerify(resolve(section.getAutoVerify()));
@@ -550,6 +554,11 @@
     submitRequirementSections.put(requirement.name(), requirement);
   }
 
+  @VisibleForTesting
+  public void clearSubmitRequirements() {
+    submitRequirementSections = new LinkedHashMap<>();
+  }
+
   /** Adds or replaces the given {@link LabelType} in this config. */
   public void upsertLabelType(LabelType labelType) {
     labelSections.put(labelType.getName(), labelType);
@@ -913,7 +922,7 @@
       Config rc, String section, String subsection, String varName, boolean useRange) {
     Permission.Builder perm = Permission.builder(varName);
     loadPermissionRules(rc, section, subsection, varName, perm, useRange);
-    return ImmutableList.copyOf(perm.build().getRules());
+    return perm.build().getRules();
   }
 
   private void loadPermissionRules(
@@ -963,6 +972,8 @@
   }
 
   private void loadSubmitRequirementSections(Config rc) {
+    checkForUnsupportedSubmitRequirementParams(rc);
+
     Map<String, String> lowerNames = new HashMap<>();
     submitRequirementSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
@@ -975,28 +986,46 @@
       }
       lowerNames.put(lower, name);
       String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
-      String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
-      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String applicabilityExpr =
+          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
+      String submittabilityExpr =
+          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
       String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
-      boolean canInherit =
-          rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
-
-      if (blockExpr == null) {
+      boolean canInherit;
+      try {
+        canInherit =
+            rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
+      } catch (IllegalArgumentException e) {
+        String canInheritValue =
+            rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
         error(
             String.format(
-                "Submit requirement '%s' does not define a submittability expression.", name));
+                "Invalid value %s.%s.%s for submit requirement '%s': %s",
+                SUBMIT_REQUIREMENT,
+                name,
+                KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+                name,
+                canInheritValue));
         continue;
       }
 
-      // TODO(SR): add expressions validation. Expressions are stored as strings so we need to
-      // validate their syntax.
+      if (submittabilityExpr == null) {
+        error(
+            String.format(
+                "Setting a submittability expression for submit requirement '%s' is required:"
+                    + " Missing %s.%s.%s",
+                name, SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION));
+        continue;
+      }
+
+      // The expressions are validated in SubmitRequirementExpressionsValidator.
 
       SubmitRequirement submitRequirement =
           SubmitRequirement.builder()
               .setName(name)
               .setDescription(Optional.ofNullable(description))
-              .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
-              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
               .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
               .setAllowOverrideInChildProjects(canInherit)
               .build();
@@ -1005,6 +1034,43 @@
     }
   }
 
+  /**
+   * Report unsupported submit requirement parameters as errors.
+   *
+   * <p>Unsupported are submit requirements parameters that
+   *
+   * <ul>
+   *   <li>are directly set in the {@code submit-requirement} section (as submit requirements are
+   *       solely defined in subsections)
+   *   <li>are unknown (maybe they were accidentally misspelled?)
+   * </ul>
+   */
+  private void checkForUnsupportedSubmitRequirementParams(Config rc) {
+    Set<String> directSubmitRequirementParams = rc.getNames(SUBMIT_REQUIREMENT);
+    if (!directSubmitRequirementParams.isEmpty()) {
+      error(
+          String.format(
+              "Submit requirements must be defined in %s.<name> subsections."
+                  + " Setting parameters directly in the %s section is not allowed: %s",
+              SUBMIT_REQUIREMENT,
+              SUBMIT_REQUIREMENT,
+              directSubmitRequirementParams.stream().sorted().collect(toImmutableList())));
+    }
+
+    for (String subsection : rc.getSubsections(SUBMIT_REQUIREMENT)) {
+      ImmutableList<String> unknownSubmitRequirementParams =
+          rc.getNames(SUBMIT_REQUIREMENT, subsection).stream()
+              .filter(p -> !SR_KEYS.contains(p))
+              .collect(toImmutableList());
+      if (!unknownSubmitRequirementParams.isEmpty()) {
+        error(
+            String.format(
+                "Unsupported parameters for submit requirement '%s': %s",
+                subsection, unknownSubmitRequirementParams));
+      }
+    }
+  }
+
   private void loadLabelSections(Config rc) {
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
     labelSections = new LinkedHashMap<>();
@@ -1041,6 +1107,8 @@
         continue;
       }
 
+      label.setDescription(Optional.ofNullable(rc.getString(LABEL, name, KEY_LABEL_DESCRIPTION)));
+
       String functionName = rc.getString(LABEL, name, KEY_FUNCTION);
       Optional<LabelFunction> function =
           functionName != null
@@ -1105,6 +1173,10 @@
               LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
       Set<Short> copyValues = new HashSet<>();
       for (String value : rc.getStringList(LABEL, name, KEY_COPY_VALUE)) {
+        if (value == null) {
+          // value is null if copyValue in project.config is set to an empty string
+          continue;
+        }
         try {
           short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
           if (!copyValues.add(copyValue)) {
@@ -1122,7 +1194,23 @@
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
       List<String> refPatterns = getStringListOrNull(rc, LABEL, name, KEY_BRANCH);
-      label.setRefPatterns(refPatterns == null ? null : ImmutableList.copyOf(refPatterns));
+      if (refPatterns == null) {
+        label.setRefPatterns(null);
+      } else {
+        for (String pattern : refPatterns) {
+          if (pattern.startsWith("^")) {
+            try {
+              Pattern.compile(pattern);
+            } catch (PatternSyntaxException e) {
+              error(
+                  String.format(
+                      "Invalid ref pattern \"%s\" in %s.%s.%s: %s",
+                      pattern, LABEL, name, KEY_BRANCH, e.getMessage()));
+            }
+          }
+        }
+        label.setRefPatterns(ImmutableList.copyOf(refPatterns));
+      }
       labelSections.put(name, label.build());
     }
   }
@@ -1543,6 +1631,11 @@
       String name = e.getKey();
       LabelType label = e.getValue();
       toUnset.remove(name);
+      if (label.getDescription().isPresent() && !label.getDescription().get().isEmpty()) {
+        rc.setString(LABEL, name, KEY_LABEL_DESCRIPTION, label.getDescription().get());
+      } else {
+        rc.unset(LABEL, name, KEY_LABEL_DESCRIPTION);
+      }
       rc.setString(LABEL, name, KEY_FUNCTION, label.getFunction().getFunctionName());
       rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
 
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 4e778a4..f1c161d 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -135,10 +135,6 @@
           e);
     } catch (RepositoryNotFoundException badName) {
       throw new BadRequestException("invalid project name: " + nameKey, badName);
-    } catch (ConfigInvalidException e) {
-      String msg = "Cannot create " + nameKey;
-      logger.atSevere().withCause(e).log(msg);
-      throw e;
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectResource.java b/java/com/google/gerrit/server/project/ProjectResource.java
index 8802758..f9fa22e 100644
--- a/java/com/google/gerrit/server/project/ProjectResource.java
+++ b/java/com/google/gerrit/server/project/ProjectResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class ProjectResource implements RestResource {
-  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND =
-      new TypeLiteral<RestView<ProjectResource>>() {};
+  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND = new TypeLiteral<>() {};
 
   private final ProjectState projectState;
   private final CurrentUser user;
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index fca1b36..ab4bb70 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -247,7 +247,7 @@
     return r;
   }
 
-  private List<ChangeInfo> executeQueryAndAutoCloseChanges(
+  private ImmutableList<ChangeInfo> executeQueryAndAutoCloseChanges(
       Predicate<ChangeData> basePredicate,
       Set<Change.Id> seenChanges,
       List<Predicate<ChangeData>> predicates,
@@ -269,7 +269,7 @@
               .call();
 
       // Result for this query that we want to return to the client.
-      List<ChangeInfo> autoCloseableChangesByBranch = new ArrayList<>();
+      ImmutableList.Builder<ChangeInfo> autoCloseableChangesByBranch = ImmutableList.builder();
 
       for (ChangeData autoCloseableChange : queryResult) {
         // Skip changes that we have already processed, either by this query or by
@@ -306,7 +306,7 @@
         }
       }
 
-      return autoCloseableChangesByBranch;
+      return autoCloseableChangesByBranch.build();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       throw new StorageException(e);
diff --git a/java/com/google/gerrit/server/project/RefResource.java b/java/com/google/gerrit/server/project/RefResource.java
index fcf6048..c67e10d 100644
--- a/java/com/google/gerrit/server/project/RefResource.java
+++ b/java/com/google/gerrit/server/project/RefResource.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.server.CurrentUser;
+import java.util.Optional;
 
 public abstract class RefResource extends ProjectResource {
 
@@ -25,6 +26,10 @@
   /** Returns the ref's name */
   public abstract String getRef();
 
-  /** Returns the ref's revision */
-  public abstract String getRevision();
+  /**
+   * Returns the ref's revision.
+   *
+   * @return the ref's revision, {@link Optional#empty()} if the ref doesn't exist (yet)
+   */
+  public abstract Optional<String> getRevision();
 }
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index a6020a3..ea92b48 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.IdentifiedUser;
@@ -40,13 +41,18 @@
     this.operationType = operationType;
   }
 
-  public void validateRefOperation(String projectName, IdentifiedUser user, RefUpdate update)
+  public void validateRefOperation(
+      String projectName,
+      IdentifiedUser user,
+      RefUpdate update,
+      ImmutableListMultimap<String, String> pushOptions)
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
             Project.builder(Project.nameKey(projectName)).build(),
             user,
-            RefOperationValidators.getCommand(update, operationType));
+            RefOperationValidators.getCommand(update, operationType),
+            pushOptions);
     try {
       refValidators.validateForRefOperation();
     } catch (ValidationException e) {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 0336e8e..1bc309c 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -108,30 +108,10 @@
     // owner and site admin can remove anyone
     PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
     PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (check(forProject.ref(change.getDest().branch()), RefPermission.WRITE_CONFIG)
-        || check(withUser, GlobalPermission.ADMINISTRATE_SERVER)) {
+    if (forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
       return true;
     }
     return false;
   }
-
-  private static boolean check(PermissionBackend.ForRef forRef, RefPermission perm)
-      throws PermissionBackendException {
-    try {
-      forRef.check(perm);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-
-  private static boolean check(PermissionBackend.WithUser withUser, GlobalPermission perm)
-      throws PermissionBackendException {
-    try {
-      withUser.check(perm);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java b/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java
new file mode 100644
index 0000000..4d15200
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java
@@ -0,0 +1,42 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+
+/**
+ * Exception that might occur when evaluating {@link SubmitRequirementPredicate} in {@link
+ * SubmitRequirementExpression}.
+ *
+ * <p>This exception will result in {@link
+ * com.google.gerrit.entities.SubmitRequirementResult.Status#ERROR} overall submit requirement
+ * evaluation status.
+ */
+public class SubmitRequirementEvaluationException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public SubmitRequirementEvaluationException(String errorMessage) {
+    super(errorMessage);
+  }
+
+  public SubmitRequirementEvaluationException(String errorMessage, Throwable why) {
+    super(errorMessage, why);
+  }
+
+  public SubmitRequirementEvaluationException(Throwable why) {
+    super(why);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
new file mode 100644
index 0000000..8717581
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -0,0 +1,182 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
+  private final DiffOperations diffOperations;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
+
+  @Inject
+  SubmitRequirementExpressionsValidator(
+      DiffOperations diffOperations,
+      ProjectConfig.Factory projectConfigFactory,
+      SubmitRequirementsEvaluator submitRequirementsEvaluator) {
+    this.diffOperations = diffOperations;
+    this.projectConfigFactory = projectConfigFactory;
+    this.submitRequirementsEvaluator = submitRequirementsEvaluator;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    try {
+      if (!event.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+        // the project.config file in refs/meta/config was not modified, hence we do not need to
+        // validate the submit requirements in it
+        return ImmutableList.of();
+      }
+
+      ProjectConfig projectConfig = getProjectConfig(event);
+      ImmutableList<CommitValidationMessage> validationMessages =
+          validateSubmitRequirementExpressions(
+              projectConfig.getSubmitRequirementSections().values());
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid submit requirement expressions in %s (revision = %s)",
+                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
+            validationMessages);
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+      throw new CommitValidationException(
+          String.format(
+              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+                  + " of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              event.commit.getName(),
+              RefNames.REFS_CONFIG,
+              event.project.getNameKey()),
+          e);
+    }
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFilesAgainstParent(
+            receiveEvent.project.getNameKey(),
+            receiveEvent.commit,
+            /* parentNum=*/ 0,
+            DiffOptions.DEFAULTS)
+        .keySet().stream()
+        .anyMatch(fileName::equals);
+  }
+
+  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectConfig;
+  }
+
+  private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
+      Collection<SubmitRequirement> submitRequirements) {
+    List<CommitValidationMessage> validationMessages = new ArrayList<>();
+    for (SubmitRequirement submitRequirement : submitRequirements) {
+      validateSubmitRequirementExpression(
+          validationMessages,
+          submitRequirement,
+          submitRequirement.submittabilityExpression(),
+          ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+      submitRequirement
+          .applicabilityExpression()
+          .ifPresent(
+              expression ->
+                  validateSubmitRequirementExpression(
+                      validationMessages,
+                      submitRequirement,
+                      expression,
+                      ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+      submitRequirement
+          .overrideExpression()
+          .ifPresent(
+              expression ->
+                  validateSubmitRequirementExpression(
+                      validationMessages,
+                      submitRequirement,
+                      expression,
+                      ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
+    }
+    return ImmutableList.copyOf(validationMessages);
+  }
+
+  private void validateSubmitRequirementExpression(
+      List<CommitValidationMessage> validationMessages,
+      SubmitRequirement submitRequirement,
+      SubmitRequirementExpression expression,
+      String configKey) {
+    try {
+      submitRequirementsEvaluator.validateExpression(expression);
+    } catch (QueryParseException e) {
+      if (validationMessages.isEmpty()) {
+        validationMessages.add(
+            new CommitValidationMessage(
+                "Invalid project configuration", ValidationMessage.Type.ERROR));
+      }
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                      + " invalid: %s",
+                  ProjectConfig.PROJECT_CONFIG,
+                  expression.expressionString(),
+                  submitRequirement.name(),
+                  ProjectConfig.SUBMIT_REQUIREMENT,
+                  submitRequirement.name(),
+                  configKey,
+                  e.getMessage()),
+              ValidationMessage.Type.ERROR));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 539edc1..1361122 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.MoreCollectors;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Label;
@@ -27,10 +29,10 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 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.lib.ObjectId;
 
@@ -48,36 +50,91 @@
    * com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
    */
   public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
-      SubmitRuleEvaluator.Factory evaluator, ChangeData cd) {
+      ChangeData cd) {
     // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
     // This doesn't have an effect since we never call this class (i.e. to evaluate submit
     // requirements) for closed changes.
-    List<SubmitRecord> records = evaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
+    List<SubmitRecord> records = cd.submitRecords(SubmitRuleOptions.defaults());
+    boolean areForced =
+        records.stream().anyMatch(record -> SubmitRecord.Status.FORCED.equals(record.status));
     List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
     ObjectId commitId = cd.currentPatchSet().commitId();
-    return records.stream()
-        .map(r -> createResult(r, labelTypes, commitId))
-        .flatMap(List::stream)
-        .collect(Collectors.toMap(sr -> sr.submitRequirement(), Function.identity()));
+    Map<String, List<SubmitRequirementResult>> srsByName =
+        records.stream()
+            // Filter out the "FORCED" submit record. This is a marker submit record that was just
+            // used to indicate that all other records were forced. "FORCED" means that the change
+            // was pushed with the %submit option bypassing submit rules.
+            .filter(r -> !SubmitRecord.Status.FORCED.equals(r.status))
+            .map(r -> createResult(r, labelTypes, commitId, areForced))
+            .flatMap(List::stream)
+            .collect(Collectors.groupingBy(sr -> sr.submitRequirement().name()));
+
+    // We convert submit records to submit requirements by generating a separate
+    // submit requirement result for each available label in each submit record.
+    // The SR status is derived from the label status of the submit record.
+    // This conversion might result in duplicate entries.
+    // One such example can be a prolog rule emitting the same label name twice.
+    // Another case might happen if two different submit rules emit the same label
+    // name. In such cases, we need to merge these entries and return a single submit
+    // requirement result. If both entries agree in their status, return any of them.
+    // Otherwise, favour the entry that is blocking submission.
+    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> result =
+        ImmutableMap.builder();
+    for (Map.Entry<String, List<SubmitRequirementResult>> entry : srsByName.entrySet()) {
+      if (entry.getValue().size() == 1) {
+        SubmitRequirementResult srResult = entry.getValue().iterator().next();
+        result.put(srResult.submitRequirement(), srResult);
+        continue;
+      }
+      // If all submit requirements with the same name match in status, return the first one.
+      List<SubmitRequirementResult> resultsSameName = entry.getValue();
+      boolean allNonBlocking = resultsSameName.stream().allMatch(sr -> sr.fulfilled());
+      if (allNonBlocking) {
+        result.put(resultsSameName.get(0).submitRequirement(), resultsSameName.get(0));
+      } else {
+        // Otherwise, return the first submit requirement result that is blocking submission.
+        Optional<SubmitRequirementResult> nonFulfilled =
+            resultsSameName.stream().filter(sr -> !sr.fulfilled()).findFirst();
+        if (nonFulfilled.isPresent()) {
+          result.put(nonFulfilled.get().submitRequirement(), nonFulfilled.get());
+        }
+      }
+    }
+    return result.build();
   }
 
   static List<SubmitRequirementResult> createResult(
-      SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId) {
+      SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId, boolean isForced) {
     List<SubmitRequirementResult> results;
-    if (record.ruleName != null && record.ruleName.equals("gerrit~DefaultSubmitRule")) {
-      results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId);
+    if (record.ruleName != null && record.ruleName.equals(DefaultSubmitRule.RULE_NAME)) {
+      results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId, isForced);
     } else {
-      results = createFromCustomSubmitRecord(record, psCommitId);
+      results = createFromCustomSubmitRecord(record, psCommitId, isForced);
     }
     logger.atFine().log("Converted submit record %s to submit requirements %s", record, results);
     return results;
   }
 
   private static List<SubmitRequirementResult> createFromDefaultSubmitRecord(
-      List<Label> labels, List<LabelType> labelTypes, ObjectId psCommitId) {
+      @Nullable List<Label> labels,
+      List<LabelType> labelTypes,
+      ObjectId psCommitId,
+      boolean isForced) {
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
+    if (labels == null) {
+      return result.build();
+    }
     for (Label label : labels) {
-      LabelType labelType = getLabelType(labelTypes, label.label);
+      if (skipSubmitRequirementFor(label)) {
+        continue;
+      }
+      Optional<LabelType> maybeLabelType = getLabelType(labelTypes, label.label);
+      if (!maybeLabelType.isPresent()) {
+        // Label type might have been removed from the project config. We don't have information
+        // if it was blocking or not, hence we skip the label.
+        continue;
+      }
+      LabelType labelType = maybeLabelType.get();
       if (!isBlocking(labelType)) {
         continue;
       }
@@ -94,13 +151,14 @@
               .submittabilityExpressionResult(
                   createExpressionResult(toExpression(atoms), mapStatus(label), atoms))
               .patchSetCommitId(psCommitId)
+              .forced(Optional.of(isForced))
               .build());
     }
     return result.build();
   }
 
   private static List<SubmitRequirementResult> createFromCustomSubmitRecord(
-      SubmitRecord record, ObjectId psCommitId) {
+      SubmitRecord record, ObjectId psCommitId, boolean isForced) {
     String ruleName = record.ruleName != null ? record.ruleName : "Custom-Rule";
     if (record.labels == null || record.labels.isEmpty()) {
       SubmitRequirement sr =
@@ -116,12 +174,19 @@
               .submitRequirement(sr)
               .submittabilityExpressionResult(
                   createExpressionResult(
-                      sr.submittabilityExpression(), mapStatus(record), ImmutableList.of(ruleName)))
+                      sr.submittabilityExpression(),
+                      mapStatus(record),
+                      ImmutableList.of(ruleName),
+                      record.errorMessage))
               .patchSetCommitId(psCommitId)
+              .forced(Optional.of(isForced))
               .build());
     }
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
     for (Label label : record.labels) {
+      if (skipSubmitRequirementFor(label)) {
+        continue;
+      }
       String expressionString = String.format("label:%s=%s", label.label, ruleName);
       SubmitRequirement sr =
           SubmitRequirement.builder()
@@ -212,9 +277,40 @@
         status == Status.FAIL ? atoms : ImmutableList.of());
   }
 
-  private static LabelType getLabelType(List<LabelType> labelTypes, String labelName) {
-    return labelTypes.stream()
-        .filter(lt -> lt.getName().equals(labelName))
-        .collect(MoreCollectors.onlyElement());
+  private static SubmitRequirementExpressionResult createExpressionResult(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> atoms,
+      String errorMessage) {
+    return SubmitRequirementExpressionResult.create(
+        expression,
+        status,
+        status == Status.PASS ? atoms : ImmutableList.of(),
+        status == Status.FAIL ? atoms : ImmutableList.of(),
+        Optional.ofNullable(Strings.emptyToNull(errorMessage)));
+  }
+
+  private static Optional<LabelType> getLabelType(List<LabelType> labelTypes, String labelName) {
+    List<LabelType> label =
+        labelTypes.stream()
+            .filter(lt -> lt.getName().equals(labelName))
+            .collect(Collectors.toList());
+    if (label.isEmpty()) {
+      // Label might have been removed from the project.
+      logger.atFine().log("Label '%s' was not found for the project.", labelName);
+      return Optional.empty();
+    } else if (label.size() > 1) {
+      logger.atWarning().log("Found more than one label definition for label name '%s'", labelName);
+      return Optional.empty();
+    }
+    return Optional.of(label.get(0));
+  }
+
+  /**
+   * Returns true if we should skip creating a "submit requirement" result out of the "submit
+   * record" label.
+   */
+  private static boolean skipSubmitRequirementFor(SubmitRecord.Label label) {
+    return label.status == SubmitRecord.Label.Status.MAY;
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index 402bb51..df836e0 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.util.Map;
 
 public interface SubmitRequirementsEvaluator {
   /**
@@ -31,7 +31,7 @@
    * @param includeLegacy if set to true, evaluate legacy {@link
    *     com.google.gerrit.entities.SubmitRecord}s and convert them to submit requirements.
    */
-  Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+  ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
       ChangeData cd, boolean includeLegacy);
 
   /** Evaluate a single {@link SubmitRequirement} using change data. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index cc2c805..c57fe27 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableMap;
@@ -24,23 +25,29 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
-import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Stream;
 
 /** Evaluates submit requirements for different change data. */
 public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
 
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
-  private final SubmitRuleEvaluator.Factory legacyEvaluator;
+  private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
+  private final SubmitRequirementsUtil submitRequirementsUtil;
+  private final OneOffRequestContext requestContext;
 
   public static Module module() {
     return new AbstractModule() {
@@ -57,10 +64,14 @@
   private SubmitRequirementsEvaluatorImpl(
       Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
       ProjectCache projectCache,
-      SubmitRuleEvaluator.Factory legacyEvaluator) {
+      PluginSetContext<SubmitRequirement> globalSubmitRequirements,
+      SubmitRequirementsUtil submitRequirementsUtil,
+      OneOffRequestContext requestContext) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
-    this.legacyEvaluator = legacyEvaluator;
+    this.globalSubmitRequirements = globalSubmitRequirements;
+    this.submitRequirementsUtil = submitRequirementsUtil;
+    this.requestContext = requestContext;
   }
 
   @Override
@@ -70,43 +81,50 @@
   }
 
   @Override
-  public Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+  public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
       ChangeData cd, boolean includeLegacy) {
-    Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements = getRequirements(cd);
-    Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
-    if (includeLegacy) {
-      Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
-          SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
-      result =
-          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-              projectConfigRequirements, legacyReqs);
+    ImmutableMap<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
+        getRequirements(cd);
+    if (!includeLegacy) {
+      return projectConfigRequirements;
     }
-    return ImmutableMap.copyOf(result);
+    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
+        SubmitRequirementsAdapter.getLegacyRequirements(cd);
+    return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
+        projectConfigRequirements, legacyReqs, cd);
   }
 
   @Override
   public SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd) {
-    SubmitRequirementExpressionResult blockingResult =
-        evaluateExpression(sr.submittabilityExpression(), cd);
+    try (ManualRequestContext ignored = requestContext.open()) {
+      // Use a request context to execute predicates as an internal user with expanded visibility.
+      // This is so that the evaluation does not depend on who is running the current request (e.g.
+      // a "ownerin" predicate with group that is not visible to the person making this request).
 
-    Optional<SubmitRequirementExpressionResult> applicabilityResult =
-        sr.applicabilityExpression().isPresent()
-            ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
-            : Optional.empty();
+      Optional<SubmitRequirementExpressionResult> applicabilityResult =
+          sr.applicabilityExpression().isPresent()
+              ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+              : Optional.empty();
+      Optional<SubmitRequirementExpressionResult> submittabilityResult = Optional.empty();
+      Optional<SubmitRequirementExpressionResult> overrideResult = Optional.empty();
+      if (!sr.applicabilityExpression().isPresent()
+          || SubmitRequirementResult.assertPass(applicabilityResult)) {
+        submittabilityResult = Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
+        overrideResult =
+            sr.overrideExpression().isPresent()
+                ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+                : Optional.empty();
+      }
 
-    Optional<SubmitRequirementExpressionResult> overrideResult =
-        sr.overrideExpression().isPresent()
-            ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
-            : Optional.empty();
-
-    return SubmitRequirementResult.builder()
-        .legacy(Optional.of(false))
-        .submitRequirement(sr)
-        .patchSetCommitId(cd.currentPatchSet().commitId())
-        .submittabilityExpressionResult(blockingResult)
-        .applicabilityExpressionResult(applicabilityResult)
-        .overrideExpressionResult(overrideResult)
-        .build();
+      return SubmitRequirementResult.builder()
+          .legacy(Optional.of(false))
+          .submitRequirement(sr)
+          .patchSetCommitId(cd.currentPatchSet().commitId())
+          .submittabilityExpressionResult(submittabilityResult)
+          .applicabilityExpressionResult(applicabilityResult)
+          .overrideExpressionResult(overrideResult)
+          .build();
+    }
   }
 
   @Override
@@ -116,20 +134,58 @@
       Predicate<ChangeData> predicate = queryBuilder.get().parse(expression.expressionString());
       PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
       return SubmitRequirementExpressionResult.create(expression, predicateResult);
-    } catch (QueryParseException e) {
+    } catch (QueryParseException | SubmitRequirementEvaluationException e) {
       return SubmitRequirementExpressionResult.error(expression, e.getMessage());
     }
   }
 
-  /** Evaluate and return submit requirements stored in this project's config and its parents. */
-  private Map<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+  /**
+   * Evaluate and return all {@link SubmitRequirement}s.
+   *
+   * <p>This includes all globally bound {@link SubmitRequirement}s, as well as requirements stored
+   * in this project's config and its parents.
+   *
+   * <p>The behaviour in case of the name match is controlled by {@link
+   * SubmitRequirement#allowOverrideInChildProjects} of global {@link SubmitRequirement}.
+   */
+  private ImmutableMap<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+    Map<String, SubmitRequirement> globalRequirements = getGlobalRequirements();
+
     ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
-    Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
-    Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
+    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+
+    ImmutableMap<String, SubmitRequirement> requirements =
+        Stream.concat(
+                globalRequirements.entrySet().stream(),
+                projectConfigRequirements.entrySet().stream())
+            .collect(
+                toImmutableMap(
+                    Map.Entry::getKey,
+                    Map.Entry::getValue,
+                    (globalSubmitRequirement, projectConfigRequirement) ->
+                        // Override with projectConfigRequirement if allowed by
+                        // globalSubmitRequirement configuration
+                        globalSubmitRequirement.allowOverrideInChildProjects()
+                            ? projectConfigRequirement
+                            : globalSubmitRequirement));
+    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> results =
+        ImmutableMap.builder();
     for (SubmitRequirement requirement : requirements.values()) {
-      result.put(requirement, evaluateRequirement(requirement, cd));
+      results.put(requirement, evaluateRequirement(requirement, cd));
     }
-    return result;
+    return results.build();
+  }
+
+  /**
+   * Returns a map of all global {@link SubmitRequirement}s, keyed by their lower-case name.
+   *
+   * <p>The global {@link SubmitRequirement}s apply to all projects and can be bound by plugins.
+   */
+  private Map<String, SubmitRequirement> getGlobalRequirements() {
+    return globalSubmitRequirements.stream()
+        .collect(
+            toImmutableMap(
+                globalRequirement -> globalRequirement.name().toLowerCase(), Function.identity()));
   }
 
   /** Evaluate the predicate recursively using change data. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index 102d3f2..c234c8c 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -14,19 +14,100 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
+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.logging.Metadata;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
  * A utility class for different operations related to {@link
  * com.google.gerrit.entities.SubmitRequirement}s.
  */
+@Singleton
 public class SubmitRequirementsUtil {
 
-  private SubmitRequirementsUtil() {}
+  @Singleton
+  static class Metrics {
+    final Counter2<String, String> submitRequirementsMatchingWithLegacy;
+    final Counter2<String, String> submitRequirementsMismatchingWithLegacy;
+    final Counter2<String, String> legacyNotInSrs;
+    final Counter2<String, String> srsNotInLegacy;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      submitRequirementsMatchingWithLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/matching_with_legacy",
+              new Description(
+                      "Total number of times there was a legacy and non-legacy "
+                          + "submit requirements with the same name for a change, "
+                          + "and the evaluation of both requirements had the same result "
+                          + "w.r.t. change submittability.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      submitRequirementsMismatchingWithLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/mismatching_with_legacy",
+              new Description(
+                      "Total number of times there was a legacy and non-legacy "
+                          + "submit requirements with the same name for a change, "
+                          + "and the evaluation of both requirements had a different result "
+                          + "w.r.t. change submittability.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      legacyNotInSrs =
+          metricMaker.newCounter(
+              "change/submit_requirements/legacy_not_in_srs",
+              new Description(
+                      "Total number of times there was a legacy submit requirement result "
+                          + "but not a project config requirement with the same name for a change.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      srsNotInLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/srs_not_in_legacy",
+              new Description(
+                      "Total number of times there was a project config submit requirement "
+                          + "result but not a legacy requirement with the same name for a change.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+    }
+  }
+
+  private final Metrics metrics;
+
+  @Inject
+  public SubmitRequirementsUtil(Metrics metrics) {
+    this.metrics = metrics;
+  }
 
   /**
    * Merge legacy and non-legacy submit requirement results. If both input maps have submit
@@ -43,9 +124,12 @@
    * @return a map that is the result of merging both input maps, while eliminating requirements
    *     with the same name and status.
    */
-  public static Map<SubmitRequirement, SubmitRequirementResult> mergeLegacyAndNonLegacyRequirements(
-      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
-      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements) {
+  public ImmutableMap<SubmitRequirement, SubmitRequirementResult>
+      mergeLegacyAndNonLegacyRequirements(
+          Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
+          Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements,
+          ChangeData cd) {
+    // Cannot use ImmutableMap.Builder here since entries in the map may be overridden.
     Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
     result.putAll(projectConfigRequirements);
     Map<String, SubmitRequirementResult> requirementsByName =
@@ -53,15 +137,52 @@
             .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
         legacyRequirements.entrySet()) {
-      String name = legacy.getKey().name().toLowerCase();
-      SubmitRequirementResult projectConfigResult = requirementsByName.get(name);
+      String srName = legacy.getKey().name().toLowerCase();
+      SubmitRequirementResult projectConfigResult = requirementsByName.get(srName);
       SubmitRequirementResult legacyResult = legacy.getValue();
-      if (projectConfigResult != null && matchByStatus(projectConfigResult, legacyResult)) {
+      // If there's no project config requirement with the same name as the legacy requirement
+      // then add the legacy SR to the result. There is no mismatch in results in this case.
+      if (projectConfigResult == null) {
+        result.put(legacy.getKey(), legacy.getValue());
+        if (shouldReportMetric(cd)) {
+          metrics.legacyNotInSrs.increment(cd.project().get(), srName);
+        }
         continue;
       }
+      if (matchByStatus(projectConfigResult, legacyResult)) {
+        // There exists a project config SR with the same name as the legacy SR, and they are
+        // matching in result. No need to include the legacy SR in the output since the project
+        // config SR is already there.
+        if (shouldReportMetric(cd)) {
+          metrics.submitRequirementsMatchingWithLegacy.increment(cd.project().get(), srName);
+        }
+        continue;
+      }
+      // There exists a project config SR with the same name as the legacy SR but they are not
+      // matching in their result. Increment the mismatch count and add the legacy SR to the result.
+      if (shouldReportMetric(cd)) {
+        metrics.submitRequirementsMismatchingWithLegacy.increment(cd.project().get(), srName);
+      }
       result.put(legacy.getKey(), legacy.getValue());
     }
-    return result;
+    Set<String> legacyNames =
+        legacyRequirements.keySet().stream()
+            .map(SubmitRequirement::name)
+            .map(String::toLowerCase)
+            .collect(Collectors.toSet());
+    for (String projectConfigSrName : requirementsByName.keySet()) {
+      if (!legacyNames.contains(projectConfigSrName) && shouldReportMetric(cd)) {
+        metrics.srsNotInLegacy.increment(cd.project().get(), projectConfigSrName);
+      }
+    }
+
+    return ImmutableMap.copyOf(result);
+  }
+
+  private static boolean shouldReportMetric(ChangeData cd) {
+    // We only care about recording differences in old and new requirements for open changes
+    // that did not have their data retrieved from the (potentially stale) change index.
+    return cd.change().isNew() && cd.getStorageConstraint() == StorageConstraint.NOTEDB_ONLY;
   }
 
   /** Returns true if both input results are equal in allowing/disallowing change submission. */
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 6c5559c..1d999dd 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -115,7 +115,12 @@
           throw new StorageException("Change not found");
         }
 
-        projectState = projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
+        Project.NameKey name = cd.project();
+        Optional<ProjectState> projectStateOptional = projectCache.get(name);
+        if (!projectStateOptional.isPresent()) {
+          throw new NoSuchProjectException(name);
+        }
+        projectState = projectStateOptional.get();
       } catch (NoSuchProjectException e) {
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
@@ -147,12 +152,14 @@
               c ->
                   c.call(
                       s -> {
-                        Optional<SubmitRecord> evaluate = s.evaluate(cd);
-                        if (evaluate.isPresent()) {
-                          evaluate.get().ruleName =
+                        Optional<SubmitRecord> record = s.evaluate(cd);
+                        if (record.isPresent() && record.get().ruleName == null) {
+                          // Only back-fill the ruleName if it was not populated by the "submit
+                          // rule".
+                          record.get().ruleName =
                               c.getPluginName() + "~" + s.getClass().getSimpleName();
                         }
-                        return evaluate;
+                        return record;
                       }))
           .filter(Optional::isPresent)
           .map(Optional::get)
@@ -168,7 +175,11 @@
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
     try (Timer0.Context ignored = submitTypeEvaluationLatency.start()) {
       try {
-        projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
+        Project.NameKey name = cd.project();
+        Optional<ProjectState> project = projectCache.get(name);
+        if (!project.isPresent()) {
+          throw new NoSuchProjectException(name);
+        }
       } catch (NoSuchProjectException e) {
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
diff --git a/java/com/google/gerrit/server/project/TagResource.java b/java/com/google/gerrit/server/project/TagResource.java
index 08ef669..d46b9ab 100644
--- a/java/com/google/gerrit/server/project/TagResource.java
+++ b/java/com/google/gerrit/server/project/TagResource.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
 
 public class TagResource extends RefResource {
-  public static final TypeLiteral<RestView<TagResource>> TAG_KIND =
-      new TypeLiteral<RestView<TagResource>>() {};
+  public static final TypeLiteral<RestView<TagResource>> TAG_KIND = new TypeLiteral<>() {};
 
   private final TagInfo tagInfo;
 
@@ -40,7 +40,7 @@
   }
 
   @Override
-  public String getRevision() {
-    return tagInfo.revision;
+  public Optional<String> getRevision() {
+    return Optional.ofNullable(tagInfo.revision);
   }
 }
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 62f8560..32b87ec 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -19,21 +19,30 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
+import java.util.Optional;
 
 public class TestLabels {
+  public static final String CODE_REVIEW_LABEL_DESCRIPTION = "Code review label description";
+  public static final String VERIFIED_LABEL_DESCRIPTION = "Verified label description";
+
   public static LabelType codeReview() {
     return label(
         LabelId.CODE_REVIEW,
+        CODE_REVIEW_LABEL_DESCRIPTION,
         value(2, "Looks good to me, approved"),
         value(1, "Looks good to me, but someone else must approve"),
         value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
+        value(-1, "I would prefer this is not submitted as is"),
+        value(-2, "This shall not be submitted"));
   }
 
   public static LabelType verified() {
     return label(
-        LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
+        LabelId.VERIFIED,
+        VERIFIED_LABEL_DESCRIPTION,
+        value(1, LabelId.VERIFIED),
+        value(0, "No score"),
+        value(-1, "Fails"));
   }
 
   public static LabelType patchSetLock() {
@@ -48,6 +57,10 @@
     return LabelValue.create((short) value, text);
   }
 
+  public static LabelType label(String name, String description, LabelValue... values) {
+    return labelBuilder(name, values).setDescription(Optional.of(description)).build();
+  }
+
   public static LabelType label(String name, LabelValue... values) {
     return labelBuilder(name, values).build();
   }
diff --git a/java/com/google/gerrit/server/query/FileEditsPredicate.java b/java/com/google/gerrit/server/query/FileEditsPredicate.java
new file mode 100644
index 0000000..7058765
--- /dev/null
+++ b/java/com/google/gerrit/server/query/FileEditsPredicate.java
@@ -0,0 +1,197 @@
+// 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.query;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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.Text;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A submit-requirement predicate that can be used in submit requirements expressions. This
+ * predicate is fulfilled if the diff between the latest patchset of the change and the base commit
+ * includes a specific file path pattern with some specific content modification. The modification
+ * could be an added, deleted or replaced content.
+ */
+public class FileEditsPredicate extends SubmitRequirementPredicate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private DiffOperations diffOperations;
+  private GitRepositoryManager repoManager;
+  private final FileEditsArgs fileEditsArgs;
+
+  public interface Factory {
+    FileEditsPredicate create(FileEditsArgs fileEditsArgs);
+  }
+
+  @AutoValue
+  public abstract static class FileEditsArgs {
+    abstract String filePattern();
+
+    abstract String editPattern();
+
+    public static FileEditsArgs create(String filePattern, String contentPattern) {
+      return new AutoValue_FileEditsPredicate_FileEditsArgs(filePattern, contentPattern);
+    }
+  }
+
+  @AssistedInject
+  public FileEditsPredicate(
+      DiffOperations diffOperations,
+      GitRepositoryManager repoManager,
+      @Assisted FileEditsPredicate.FileEditsArgs fileEditsArgs) {
+    super("fileEdits", fileEditsArgs.filePattern() + "," + fileEditsArgs.editPattern());
+    this.diffOperations = diffOperations;
+    this.repoManager = repoManager;
+    this.fileEditsArgs = fileEditsArgs;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    try {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              cd.project(),
+              cd.currentPatchSet().commitId(),
+              /* parentNum= */ 0,
+              DiffOptions.DEFAULTS);
+      FileDiffOutput firstDiff =
+          Iterables.getFirst(modifiedFiles.values(), /* defaultValue= */ null);
+      if (firstDiff == null) {
+        // No available diffs. We cannot identify old and new commit IDs.
+        // engine.fail();
+        return false;
+      }
+
+      Pattern filePattern = null;
+      Pattern editPattern = null;
+      if (fileEditsArgs.filePattern().startsWith("^")) {
+        // We validated the pattern before creating this predicate. No need to revalidate.
+        String pattern = fileEditsArgs.filePattern();
+        filePattern = Pattern.compile(pattern);
+      }
+      if (fileEditsArgs.editPattern().startsWith("^")) {
+        // We validated the pattern before creating this predicate. No need to revalidate.
+        String pattern = fileEditsArgs.editPattern();
+        editPattern = Pattern.compile(pattern);
+      }
+      try (Repository repo = repoManager.openRepository(cd.project());
+          ObjectReader reader = repo.newObjectReader();
+          RevWalk rw = new RevWalk(reader)) {
+        RevTree aTree =
+            firstDiff.oldCommitId().equals(ObjectId.zeroId())
+                ? null
+                : rw.parseTree(firstDiff.oldCommitId());
+        RevTree bTree = rw.parseCommit(firstDiff.newCommitId()).getTree();
+
+        for (FileDiffOutput entry : modifiedFiles.values()) {
+          String newName =
+              FilePathAdapter.getNewPath(entry.oldPath(), entry.newPath(), entry.changeType());
+          String oldName = FilePathAdapter.getOldPath(entry.oldPath(), entry.changeType());
+
+          if (Patch.isMagic(newName)) {
+            continue;
+          }
+
+          if (match(newName, fileEditsArgs.filePattern(), filePattern)
+              || (oldName != null && match(oldName, fileEditsArgs.filePattern(), filePattern))) {
+            List<Edit> edits =
+                entry.edits().stream().map(TaggedEdit::jgitEdit).collect(Collectors.toList());
+            if (edits.isEmpty()) {
+              continue;
+            }
+            Text tA;
+            if (oldName != null) {
+              tA = load(aTree, oldName, reader);
+            } else {
+              tA = load(aTree, newName, reader);
+            }
+            Text tB = load(bTree, newName, reader);
+            for (Edit edit : edits) {
+              if (tA != Text.EMPTY) {
+                String aDiff = tA.getString(edit.getBeginA(), edit.getEndA(), true);
+                if (match(aDiff, fileEditsArgs.editPattern(), editPattern)) {
+                  return true;
+                }
+              }
+              if (tB != Text.EMPTY) {
+                String bDiff = tB.getString(edit.getBeginB(), edit.getEndB(), true);
+                if (match(bDiff, fileEditsArgs.editPattern(), editPattern)) {
+                  return true;
+                }
+              }
+            }
+          }
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Error while evaluating commit edits.");
+        return false;
+      }
+    } catch (DiffNotAvailableException e) {
+      logger.atSevere().withCause(e).log("Diff error while evaluating commit edits.");
+      return false;
+    }
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 10;
+  }
+
+  private Text load(@Nullable ObjectId tree, String path, ObjectReader reader) throws IOException {
+    if (tree == null || path == null) {
+      return Text.EMPTY;
+    }
+    final TreeWalk tw = TreeWalk.forPath(reader, path, tree);
+    if (tw == null) {
+      return Text.EMPTY;
+    }
+    if (tw.getFileMode(0).getObjectType() != Constants.OBJ_BLOB) {
+      return Text.EMPTY;
+    }
+    return new Text(reader.open(tw.getObjectId(0), Constants.OBJ_BLOB));
+  }
+
+  private boolean match(String text, String search, @Nullable Pattern searchPattern) {
+    return searchPattern == null ? text.contains(search) : searchPattern.matcher(text).find();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 4a95b2e..6ab51c5 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -127,9 +127,7 @@
           .change(changeNotes.get())
           .check(ChangePermission.READ);
     } catch (AuthException e) {
-      String msg = String.format("change %s not found", change);
-      logger.atSevere().withCause(e).log(msg);
-      throw error(msg);
+      throw error(String.format("change %s not found", change), e);
     }
 
     return AccountPredicates.cansee(args, changeNotes.get());
diff --git a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index 0252a06..e293285 100644
--- a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.account;
 
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -36,15 +35,12 @@
   @Override
   public boolean match(AccountState accountState) {
     try {
-      permissionBackend
+      return permissionBackend
           .absentUser(accountState.account().id())
           .change(changeNotes)
-          .check(ChangePermission.READ);
-      return true;
+          .test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       throw new StorageException("Failed to check if account can see change", e);
-    } catch (AuthException e) {
-      return false;
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 1d67009..a0a9f71 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -82,7 +82,7 @@
       Joiner.on(", ")
           .appendTo(
               msg, accountStates.stream().map(a -> a.account().id().toString()).collect(toList()));
-      logger.atWarning().log(msg.toString());
+      logger.atWarning().log("%s", msg);
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 4dedbb5..6e433c5 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Entity representing all required information to match predicates for copying approvals. */
 @AutoValue
@@ -41,8 +43,23 @@
   /** {@link ChangeKind} of the delta between the origin and target patch set. */
   public abstract ChangeKind changeKind();
 
+  /** Whether the new patch set is a merge commit. */
+  public abstract boolean isMerge();
+
+  /** {@link RevWalk} of the repository for the current commit. */
+  public abstract RevWalk revWalk();
+
+  /** {@link RevWalk} of the repository for the current commit. */
+  public abstract Config repoConfig();
+
   public static ApprovalContext create(
-      ChangeNotes changeNotes, PatchSetApproval psa, PatchSet patchSet, ChangeKind changeKind) {
+      ChangeNotes changeNotes,
+      PatchSetApproval psa,
+      PatchSet patchSet,
+      ChangeKind changeKind,
+      boolean isMerge,
+      RevWalk revWalk,
+      Config repoConfig) {
     checkState(
         psa.patchSetId().changeId().equals(patchSet.id().changeId()),
         "approval and target must be the same change. got: %s, %s",
@@ -54,6 +71,7 @@
     // As explained in the commit message of this change doing this check is only possible if there
     // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
     // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
-    return new AutoValue_ApprovalContext(psa, patchSet, changeNotes, changeKind);
+    return new AutoValue_ApprovalContext(
+        psa, patchSet, changeNotes, changeKind, isMerge, revWalk, repoConfig);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index 819f319..11749cc 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.query.approval;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Enums;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.client.ChangeKind;
@@ -24,6 +28,7 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.inject.Inject;
 import java.util.Arrays;
+import java.util.Optional;
 
 public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
   private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef =
@@ -51,13 +56,37 @@
   }
 
   @Operator
-  public Predicate<ApprovalContext> changekind(String term) throws QueryParseException {
-    return new ChangeKindPredicate(toEnumValue(ChangeKind.class, term));
+  public Predicate<ApprovalContext> changekind(String value) throws QueryParseException {
+    return parseEnumValue(ChangeKind.class, value)
+        .map(ChangeKindPredicate::new)
+        .orElseThrow(
+            () ->
+                new QueryParseException(
+                    String.format(
+                        "%s is not a valid value for operator 'changekind'. Valid values: %s",
+                        value, formatEnumValues(ChangeKind.class))));
   }
 
   @Operator
-  public Predicate<ApprovalContext> is(String term) throws QueryParseException {
-    return magicValuePredicate.create(toEnumValue(MagicValuePredicate.MagicValue.class, term));
+  public Predicate<ApprovalContext> is(String value) throws QueryParseException {
+    // try to parse exact value
+    Optional<Integer> exactValue = Optional.ofNullable(Ints.tryParse(value));
+    if (exactValue.isPresent()) {
+      return new ExactValuePredicate(exactValue.get().shortValue());
+    }
+
+    // try to parse magic value
+    Optional<MagicValuePredicate.MagicValue> magicValue =
+        parseEnumValue(MagicValuePredicate.MagicValue.class, value);
+    if (magicValue.isPresent()) {
+      return magicValuePredicate.create(magicValue.get());
+    }
+
+    // it's neither an exact value nor a magic value
+    throw new QueryParseException(
+        String.format(
+            "%s is not a valid value for operator 'is'. Valid values: %s or integer",
+            value, formatEnumValues(MagicValuePredicate.MagicValue.class)));
   }
 
   @Operator
@@ -77,21 +106,21 @@
     }
     throw error(
         String.format(
-            "'%s' is not a supported argument for has. only 'unchanged-files' is supported",
+            "'%s' is not a valid value for operator 'has'."
+                + " The only valid value is 'unchanged-files'.",
             value));
   }
 
-  private static <T extends Enum<T>> T toEnumValue(Class<T> clazz, String term)
-      throws QueryParseException {
-    try {
-      return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
-    } catch (IllegalArgumentException e) {
-      throw new QueryParseException(
-          String.format(
-              "%s is not a valid term. valid options: %s",
-              term, Arrays.asList(clazz.getEnumConstants())),
-          e);
-    }
+  private static <T extends Enum<T>> Optional<T> parseEnumValue(Class<T> clazz, String value) {
+    return Optional.ofNullable(
+        Enums.getIfPresent(clazz, value.toUpperCase().replace('-', '_')).orNull());
+  }
+
+  private <T extends Enum<T>> String formatEnumValues(Class<T> clazz) {
+    return Arrays.stream(clazz.getEnumConstants())
+        .map(Object::toString)
+        .sorted()
+        .collect(joining(", "));
   }
 
   private AccountGroup.UUID parseGroupOrThrow(String maybeUUID) throws QueryParseException {
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
index 78711fd..f519b16 100644
--- a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -32,7 +32,39 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    return ctx.changeKind().equals(changeKind);
+    if (ctx.changeKind().equals(changeKind)) {
+      // The configured change kind (changeKind) on which approvals should be copied matches the
+      // actual change kind (ctx.changeKind()).
+      return true;
+    }
+
+    // If the configured change kind (changeKind) is REWORK it means that all kind of change kinds
+    // should be matched, since any other change kind is just a more trivial version of a rework.
+    if (changeKind == ChangeKind.REWORK) {
+      return true;
+    }
+
+    // If the actual change kind (ctx.changeKind()) is NO_CHANGE it is also matched if the
+    // configured change kind (changeKind) is:
+    // * TRIVIAL_REBASE: since NO_CHANGE is a special kind of a trivial rebase
+    // * NO_CODE_CHANGE: if there is no change, there is also no code change
+    // * MERGE_FIRST_PARENT_UPDATE (only if the new patch set is a merge commit): if votes should be
+    //   copied on first parent update, they should also be copied if there was no change
+    //
+    // Motivation:
+    // * https://gerrit-review.googlesource.com/c/gerrit/+/74690
+    // * There is no practical use case where you would want votes to be copied on
+    //   TRIVIAL_REBASE|NO_CODE_CHANGE|MERGE_FIRST_PARENT_UPDATE but not on NO_CHANGE. Matching
+    //   NO_CHANGE implicitly for these change kinds makes configuring copy conditions easier (as
+    //   users can simply configure "changekind:<CHANGE-KIND>", rather than
+    //   "changekind:<CHANGE-KIND> OR changekind:NO_CHANGE").
+    // * This preserves backwards compatibility with the deprecated boolean flags for copying
+    //   approvals based on the change kind ('copyAllScoresOnTrivialRebase',
+    //   'copyAllScoresIfNoCodeChange' and 'copyAllScoresOnMergeFirstParentUpdate').
+    return ctx.changeKind() == ChangeKind.NO_CHANGE
+        && (changeKind == ChangeKind.TRIVIAL_REBASE
+            || changeKind == ChangeKind.NO_CODE_CHANGE
+            || (ctx.isMerge() && changeKind == ChangeKind.MERGE_FIRST_PARENT_UPDATE));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
new file mode 100644
index 0000000..1f36f8a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.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.query.approval;
+
+import com.google.gerrit.index.query.Predicate;
+import java.util.Collection;
+
+/** Predicate that matches patch set approvals that have a given voting value. */
+public class ExactValuePredicate extends ApprovalPredicate {
+  private final short votingValue;
+
+  public ExactValuePredicate(short votingValue) {
+    this.votingValue = votingValue;
+  }
+
+  @Override
+  public boolean match(ApprovalContext approvalContext) {
+    return votingValue == approvalContext.patchSetApproval().value();
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ExactValuePredicate(votingValue);
+  }
+
+  @Override
+  public int hashCode() {
+    return Short.valueOf(votingValue).hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    return (other instanceof ExactValuePredicate)
+        && votingValue == ((ExactValuePredicate) other).votingValue;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
index de7dd0a..2a72c49 100644
--- a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -23,7 +23,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 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.patch.DiffOptions;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -58,17 +59,30 @@
     Integer parentNum =
         isInitialCommit(ctx.changeNotes().getProjectName(), targetPatchSet.commitId()) ? 0 : 1;
     try {
-      Map<String, FileDiffOutput> baseVsCurrent =
-          diffOperations.listModifiedFilesAgainstParent(
-              ctx.changeNotes().getProjectName(), targetPatchSet.commitId(), parentNum);
-      Map<String, FileDiffOutput> baseVsPrior =
-          diffOperations.listModifiedFilesAgainstParent(
-              ctx.changeNotes().getProjectName(), sourcePatchSet.commitId(), parentNum);
-      Map<String, FileDiffOutput> priorVsCurrent =
-          diffOperations.listModifiedFiles(
+      Map<String, ModifiedFile> baseVsCurrent =
+          diffOperations.loadModifiedFilesAgainstParent(
+              ctx.changeNotes().getProjectName(),
+              targetPatchSet.commitId(),
+              parentNum,
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
+      Map<String, ModifiedFile> baseVsPrior =
+          diffOperations.loadModifiedFilesAgainstParent(
               ctx.changeNotes().getProjectName(),
               sourcePatchSet.commitId(),
-              targetPatchSet.commitId());
+              parentNum,
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
+      Map<String, ModifiedFile> priorVsCurrent =
+          diffOperations.loadModifiedFiles(
+              ctx.changeNotes().getProjectName(),
+              sourcePatchSet.commitId(),
+              targetPatchSet.commitId(),
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
       return match(baseVsCurrent, baseVsPrior, priorVsCurrent);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
@@ -84,9 +98,9 @@
    * {@link ChangeType} matches for each modified file.
    */
   public boolean match(
-      Map<String, FileDiffOutput> baseVsCurrent,
-      Map<String, FileDiffOutput> baseVsPrior,
-      Map<String, FileDiffOutput> priorVsCurrent) {
+      Map<String, ModifiedFile> baseVsCurrent,
+      Map<String, ModifiedFile> baseVsPrior,
+      Map<String, ModifiedFile> priorVsCurrent) {
     Set<String> allFiles = new HashSet<>();
     allFiles.addAll(baseVsCurrent.keySet());
     allFiles.addAll(baseVsPrior.keySet());
@@ -94,17 +108,17 @@
       if (Patch.isMagic(file)) {
         continue;
       }
-      FileDiffOutput fileDiffOutput1 = baseVsCurrent.get(file);
-      FileDiffOutput fileDiffOutput2 = baseVsPrior.get(file);
+      ModifiedFile modifiedFile1 = baseVsCurrent.get(file);
+      ModifiedFile modifiedFile2 = baseVsPrior.get(file);
       if (!priorVsCurrent.containsKey(file)) {
         // If the file is not modified between prior and current patchsets, then scan safely skip
-        // it. The file might has been modified due to rebase.
+        // it. The file might have been modified due to rebase.
         continue;
       }
-      if (fileDiffOutput1 == null || fileDiffOutput2 == null) {
+      if (modifiedFile1 == null || modifiedFile2 == null) {
         return false;
       }
-      if (!fileDiffOutput2.changeType().equals(fileDiffOutput1.changeType())) {
+      if (!modifiedFile2.changeType().equals(modifiedFile1.changeType())) {
         return false;
       }
     }
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
index ac6720d..2aef703 100644
--- a/java/com/google/gerrit/server/query/approval/UserInPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.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.query.approval;
 
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 8f92d9a..2514989 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -17,14 +17,14 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 /**
  * Predicate that matches a {@link Timestamp} field from the index in a range from the passed {@code
  * String} representation of the Timestamp value to the maximum supported time.
  */
 public class AfterPredicate extends TimestampRangeChangePredicate {
-  protected final Date cut;
+  protected final Instant cut;
 
   public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
@@ -33,13 +33,13 @@
   }
 
   @Override
-  public Date getMinTimestamp() {
+  public Instant getMinTimestamp() {
     return cut;
   }
 
   @Override
-  public Date getMaxTimestamp() {
-    return new Date(Long.MAX_VALUE);
+  public Instant getMaxTimestamp() {
+    return Instant.ofEpochMilli(Long.MAX_VALUE);
   }
 
   @Override
@@ -48,6 +48,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() >= cut.getTime();
+    return valueTimestamp.getTime() >= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index d38789f..c1138bd 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -21,26 +21,27 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
-  protected final long cut;
+  protected final Instant cut;
 
   public AgePredicate(String value) {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
 
     long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
     long ms = MILLISECONDS.convert(s, SECONDS);
-    this.cut = TimeUtil.nowMs() - ms;
+    this.cut = Instant.ofEpochMilli(TimeUtil.nowMs() - ms);
   }
 
   @Override
-  public Timestamp getMinTimestamp() {
-    return new Timestamp(0);
+  public Instant getMinTimestamp() {
+    return Instant.EPOCH;
   }
 
   @Override
-  public Timestamp getMaxTimestamp() {
-    return new Timestamp(cut);
+  public Instant getMaxTimestamp() {
+    return cut;
   }
 
   @Override
@@ -49,6 +50,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() <= cut;
+    return valueTimestamp.getTime() <= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 6e28ce6..5d682fb 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -17,14 +17,14 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 /**
  * Predicate that matches a {@link Timestamp} field from the index in a range from the the epoch to
  * the passed {@code String} representation of the Timestamp value.
  */
 public class BeforePredicate extends TimestampRangeChangePredicate {
-  protected final Date cut;
+  protected final Instant cut;
 
   public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
@@ -33,12 +33,12 @@
   }
 
   @Override
-  public Date getMinTimestamp() {
-    return new Date(0);
+  public Instant getMinTimestamp() {
+    return Instant.EPOCH;
   }
 
   @Override
-  public Date getMaxTimestamp() {
+  public Instant getMaxTimestamp() {
     return cut;
   }
 
@@ -48,6 +48,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() <= cut.getTime();
+    return valueTimestamp.getTime() <= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 9961519..ad422bc 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -75,8 +75,6 @@
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
 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.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -98,7 +96,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 java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -278,7 +276,7 @@
             .id(PatchSet.id(id, currentPatchSetId))
             .commitId(commitId)
             .uploader(Account.id(1000))
-            .createdOn(TimeUtil.nowTs())
+            .createdOn(TimeUtil.now())
             .build();
     return cd;
   }
@@ -290,7 +288,6 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
-  private final ExperimentFeatures experimentFeatures;
   private final GitRepositoryManager repoManager;
   private final MergeUtil.Factory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
@@ -300,6 +297,7 @@
   private final TrackingFooters trackingFooters;
   private final PureRevert pureRevert;
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
+  private final SubmitRequirementsUtil submitRequirementsUtil;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   // Required assisted injected fields.
@@ -360,7 +358,7 @@
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
-  private Optional<Timestamp> mergedOn;
+  private Optional<Instant> mergedOn;
   private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
 
@@ -372,7 +370,6 @@
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
-      ExperimentFeatures experimentFeatures,
       GitRepositoryManager repoManager,
       MergeUtil.Factory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
@@ -382,6 +379,7 @@
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
+      SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
@@ -392,7 +390,6 @@
     this.cmUtil = cmUtil;
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
-    this.experimentFeatures = experimentFeatures;
     this.repoManager = repoManager;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
@@ -403,6 +400,7 @@
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
+    this.submitRequirementsUtil = submitRequirementsUtil;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
 
     this.project = project;
@@ -424,6 +422,10 @@
     return this;
   }
 
+  public StorageConstraint getStorageConstraint() {
+    return storageConstraint;
+  }
+
   /** Returns {@code true} if we allow reading data from NoteDb. */
   public boolean lazyload() {
     return storageConstraint.ordinal()
@@ -707,7 +709,7 @@
    * @throws StorageException if {@code lazyLoad} is off, {@link ChangeNotes} can not be loaded
    *     because we do not expect to call the database.
    */
-  public Optional<Timestamp> getMergedOn() throws StorageException {
+  public Optional<Instant> getMergedOn() throws StorageException {
     if (mergedOn == null) {
       // The value was not loaded yet, try to get from the database.
       mergedOn = notes().getMergedOn();
@@ -716,7 +718,7 @@
   }
 
   /** Sets the value e.g. when loading from index. */
-  public void setMergedOn(@Nullable Timestamp mergedOn) {
+  public void setMergedOn(@Nullable Instant mergedOn) {
     this.mergedOn = Optional.ofNullable(mergedOn);
   }
 
@@ -770,7 +772,7 @@
       if (!lazyload()) {
         return ImmutableListMultimap.of();
       }
-      allApprovals = approvalsUtil.byChange(notes());
+      allApprovals = approvalsUtil.byChangeExcludingCopiedApprovals(notes());
     }
     return allApprovals;
   }
@@ -940,6 +942,18 @@
   }
 
   /**
+   * Similar to {@link #submitRequirements}, except that it also converts submit records resulting
+   * from the evaluation of legacy submit rules to submit requirements.
+   */
+  public Map<SubmitRequirement, SubmitRequirementResult> submitRequirementsIncludingLegacy() {
+    Map<SubmitRequirement, SubmitRequirementResult> projectConfigReqs = submitRequirements();
+    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
+        SubmitRequirementsAdapter.getLegacyRequirements(this);
+    return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
+        projectConfigReqs, legacyReqs, this);
+  }
+
+  /**
    * Get all evaluated submit requirements for this change, including those from parent projects.
    * For closed changes, submit requirements are read from the change notes. For active changes,
    * submit requirements are evaluated online.
@@ -948,10 +962,6 @@
    * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
-    if (!experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)) {
-      return Collections.emptyMap();
-    }
     if (submitRequirements == null) {
       if (!lazyload()) {
         return Collections.emptyMap();
@@ -960,34 +970,21 @@
       if (c == null || !c.isClosed()) {
         // Open changes: Evaluate submit requirements online.
         submitRequirements =
-            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ true);
+            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ false);
         return submitRequirements;
       }
       // Closed changes: Load submit requirement results from NoteDb.
-      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
+      submitRequirements =
           notes().getSubmitRequirementsResult().stream()
               .filter(r -> !r.isLegacy())
               .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
-      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
-          SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
-      submitRequirements =
-          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-              projectConfigRequirements, legacyRequirements);
     }
     return submitRequirements;
   }
 
   public void setSubmitRequirements(
       Map<SubmitRequirement, SubmitRequirementResult> submitRequirements) {
-    if (!experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)) {
-      // Only set back values from the index if the experiment is not active. While the experiment
-      // is active, we want
-      // to compute SRs from scratch to ensure fresh results.
-      // TODO(ghareeb, hiesel): Remove this.
-      this.submitRequirements = submitRequirements;
-    }
+    this.submitRequirements = submitRequirements;
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
@@ -1348,7 +1345,7 @@
 
     public abstract Account.Id author();
 
-    public abstract Timestamp ts();
+    public abstract Instant ts();
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 7abe4b7..259239b 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -21,12 +23,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 
 /** Predicates that match against {@link ChangeData}. */
 public class ChangePredicates {
@@ -76,8 +81,37 @@
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
    */
-  public static Predicate<ChangeData> draftBy(Account.Id id) {
-    return new ChangeIndexCardinalPredicate(ChangeField.DRAFTBY, id.toString(), 20);
+  public static Predicate<ChangeData> draftBy(
+      boolean computeFromAllUsersRepository, CommentsUtil commentsUtil, Account.Id id) {
+    if (!computeFromAllUsersRepository) {
+      return new ChangeIndexCardinalPredicate(ChangeField.DRAFTBY, id.toString(), 20);
+    }
+    Set<Predicate<ChangeData>> changeIdPredicates =
+        commentsUtil.getChangesWithDrafts(id).stream()
+            .map(ChangePredicates::idStr)
+            .collect(toImmutableSet());
+    return changeIdPredicates.isEmpty()
+        ? ChangeIndexPredicate.none()
+        : Predicate.or(changeIdPredicates);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
+   */
+  public static Predicate<ChangeData> starBy(
+      boolean computeFromAllUsersRepository,
+      StarredChangesUtil starredChangesUtil,
+      Account.Id id,
+      String label) {
+    if (!computeFromAllUsersRepository) {
+      return new StarPredicate(id, label);
+    }
+    Set<Predicate<ChangeData>> starredChanges =
+        starredChangesUtil.byAccountId(id, label).stream()
+            .map(ChangePredicates::idStr)
+            .collect(toImmutableSet());
+    return starredChanges.isEmpty() ? ChangeIndexPredicate.none() : Predicate.or(starredChanges);
   }
 
   /**
@@ -173,6 +207,11 @@
     return new ChangeIndexPredicate(ChangeField.FUZZY_TOPIC, topic);
   }
 
+  /** Returns a predicate that matches changes in the provided {@code topic}. Used with prefixes */
+  public static Predicate<ChangeData> prefixTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_TOPIC, topic);
+  }
+
   /** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
   public static Predicate<ChangeData> submissionId(String changeSet) {
     return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
@@ -197,6 +236,15 @@
         ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
   }
 
+  /**
+   * Returns a predicate that matches changes in the provided {@code hashtag}. Used with prefixes
+   */
+  public static Predicate<ChangeData> prefixHashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.PREFIX_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
   /** Returns a predicate that matches changes that modified the provided {@code file}. */
   public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
     Predicate<ChangeData> eqPath = path(file);
@@ -222,6 +270,14 @@
   }
 
   /**
+   * Returns a predicate that matches changes with the provided {@code footer} name in their commit
+   * message.
+   */
+  public static Predicate<ChangeData> hasFooter(String footerName) {
+    return new ChangeIndexPredicate(ChangeField.FOOTER_NAME, footerName);
+  }
+
+  /**
    * Returns a predicate that matches changes that modified files in the provided {@code directory}.
    */
   public static Predicate<ChangeData> directory(String directory) {
@@ -311,4 +367,23 @@
   public static Predicate<ChangeData> submitRuleStatus(String value) {
     return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
   }
+
+  /**
+   * Returns a predicate that matches with changes that are pure reverts if {@code value} is equal
+   * to "1", or non-pure reverts if {@code value} is "0".
+   */
+  public static Predicate<ChangeData> pureRevert(String value) {
+    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT, value);
+  }
+
+  /**
+   * Returns a predicate that matches with changes that are submittable if {@code value} is equal to
+   * "1", or non-submittable if {@code value} is "0".
+   *
+   * <p>The computation of this field is based on the evaluation of {@link
+   * com.google.gerrit.entities.SubmitRequirement}s.
+   */
+  public static Predicate<ChangeData> isSubmittable(String value) {
+    return new ChangeIndexPredicate(ChangeField.IS_SUBMITTABLE, value);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index e36dbfc..c494024 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -43,6 +44,7 @@
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaUtil;
@@ -59,6 +61,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.DestinationList;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupMembers;
@@ -71,6 +74,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.HasOperandAliasConfig;
 import com.google.gerrit.server.config.OperatorAliasConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
@@ -82,6 +86,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.PredicateArgs.ValOp;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.submit.SubmitDryRun;
 import com.google.inject.Inject;
@@ -93,6 +98,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -140,7 +146,7 @@
   static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
 
   // NOTE: As new search operations are added, please keep the suggestions in
-  // gr-search-bar.js up to date.
+  // gr-search-bar.ts up to date.
 
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
@@ -162,6 +168,7 @@
   public static final String FIELD_EXTENSION = "extension";
   public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
   public static final String FIELD_FOOTER = "footer";
+  public static final String FIELD_FOOTER_NAME = "footernames";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -179,6 +186,7 @@
   public static final String FIELD_MERGEABLE = "mergeable2";
   public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
+  public static final String FIELD_MESSAGE_EXACT = "messageexact";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTOF = "parentof";
@@ -204,15 +212,18 @@
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
   public static final String FIELD_REVERTOF = "revertof";
+  public static final String FIELD_PURE_REVERT = "ispurerevert";
   public static final String FIELD_CHERRYPICK = "cherrypick";
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
+  public static final String FIELD_IS_SUBMITTABLE = "issubmittable";
 
   public static final String ARG_ID_NAME = "name";
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
+  public static final String ARG_COUNT = "count";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
 
@@ -256,6 +267,7 @@
     final OperatorAliasConfig operatorAliasConfig;
     final boolean indexMergeable;
     final boolean conflictsPredicateEnabled;
+    final ExperimentFeatures experimentFeatures;
     final HasOperandAliasConfig hasOperandAliasConfig;
     final PluginSetContext<SubmitRule> submitRules;
 
@@ -291,6 +303,7 @@
         GroupMembers groupMembers,
         OperatorAliasConfig operatorAliasConfig,
         @GerritServerConfig Config gerritConfig,
+        ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
         PluginSetContext<SubmitRule> submitRules) {
@@ -323,6 +336,7 @@
           operatorAliasConfig,
           MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex(),
           gerritConfig.getBoolean("change", null, "conflictsPredicateEnabled", true),
+          experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
           submitRules);
@@ -357,6 +371,7 @@
         OperatorAliasConfig operatorAliasConfig,
         boolean indexMergeable,
         boolean conflictsPredicateEnabled,
+        ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
         PluginSetContext<SubmitRule> submitRules) {
@@ -389,6 +404,7 @@
       this.operatorAliasConfig = operatorAliasConfig;
       this.indexMergeable = indexMergeable;
       this.conflictsPredicateEnabled = conflictsPredicateEnabled;
+      this.experimentFeatures = experimentFeatures;
       this.hasOperandAliasConfig = hasOperandAliasConfig;
       this.submitRules = submitRules;
     }
@@ -423,6 +439,7 @@
           operatorAliasConfig,
           indexMergeable,
           conflictsPredicateEnabled,
+          experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
           submitRules);
@@ -467,6 +484,11 @@
 
   private final Arguments args;
   protected Map<String, String> hasOperandAliases = Collections.emptyMap();
+  private Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
+
+  private static final Splitter RULE_SPLITTER = Splitter.on("=");
+  private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
+  private static final Splitter LABEL_SPLITTER = Splitter.on(",");
 
   @Inject
   ChangeQueryBuilder(Arguments args) {
@@ -516,22 +538,14 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
-      throw new QueryParseException(
-          String.format(
-              "'%s' operator is not supported by change index version", OPERATOR_MERGED_BEFORE));
-    }
+    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
         ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
-      throw new QueryParseException(
-          String.format(
-              "'%s' operator is not supported by change index version", OPERATOR_MERGED_AFTER));
-    }
+    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
         ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
@@ -565,7 +579,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> status(String statusName) {
+  public Predicate<ChangeData> status(String statusName) throws QueryParseException {
     if ("reviewed".equalsIgnoreCase(statusName)) {
       return ChangePredicates.unreviewed();
     }
@@ -580,21 +594,16 @@
   public Predicate<ChangeData> rule(String value) throws QueryParseException {
     String ruleNameArg = value;
     String statusArg = null;
-    String[] queryArgs = value.split("=");
-    if (queryArgs.length > 2) {
+    List<String> queryArgs = RULE_SPLITTER.splitToList(value);
+    if (queryArgs.size() > 2) {
       throw new QueryParseException(
           "Invalid query arguments. Correct format is 'rule:<rule_name>=<status>' "
               + "with <rule_name> in the form of <plugin>~<rule>. For Gerrit core rules, "
-              + "rule name should be specified either as gerrit~<rule> or <rule>.");
+              + "rule name should be specified as gerrit~<rule>.");
     }
-    if (queryArgs.length == 2) {
-      ruleNameArg = queryArgs[0];
-      statusArg = queryArgs[1];
-    }
-
-    // If ruleName is not prefixed by the plugin name, add the "gerrit~" prefix to it.
-    if (!ruleNameArg.contains("~")) {
-      ruleNameArg = "gerrit~" + ruleNameArg;
+    if (queryArgs.size() == 2) {
+      ruleNameArg = queryArgs.get(0);
+      statusArg = queryArgs.get(1);
     }
 
     return statusArg == null
@@ -621,10 +630,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-        throw new QueryParseException(
-            "'has:attention' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -633,7 +639,7 @@
     }
 
     // for plugins the value will be operandName_pluginName
-    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
       ChangeHasOperandFactory op = args.hasOperands.get(names.get(1), names.get(0));
       if (op != null) {
@@ -667,20 +673,13 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-        throw new QueryParseException(
-            "'is:uploader' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.UPLOADER, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return Predicate.and(
-            Predicate.not(new BooleanPredicate(ChangeField.WIP)),
-            ReviewerPredicate.reviewer(self()));
-      }
-      return ReviewerPredicate.reviewer(self());
+      return Predicate.and(
+          Predicate.not(new BooleanPredicate(ChangeField.WIP)), ReviewerPredicate.reviewer(self()));
     }
 
     if ("cc".equalsIgnoreCase(value)) {
@@ -695,25 +694,16 @@
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.MERGE)) {
-        return new BooleanPredicate(ChangeField.MERGE);
-      }
-      throw new QueryParseException("'is:merge' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.MERGE, "is:merge");
+      return new BooleanPredicate(ChangeField.MERGE);
     }
 
     if ("private".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.PRIVATE)) {
-        return new BooleanPredicate(ChangeField.PRIVATE);
-      }
-      throw new QueryParseException(
-          "'is:private' operator is not supported by change index version");
+      return new BooleanPredicate(ChangeField.PRIVATE);
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-        throw new QueryParseException(
-            "'is:attention' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
@@ -725,16 +715,25 @@
       return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
+    if ("pure-revert".equalsIgnoreCase(value)) {
+      checkFieldAvailable(ChangeField.IS_PURE_REVERT, "is:pure-revert");
+      return ChangePredicates.pureRevert("1");
+    }
+
     if ("submittable".equalsIgnoreCase(value)) {
-      // SubmittablePredicate will match if *any* of the submit records are OK,
-      // but we need to check that they're *all* OK, so check that none of the
-      // submit records match any of the negative cases. To avoid checking yet
-      // more negative cases for CLOSED and FORCED, instead make sure at least
-      // one submit record is OK.
-      return Predicate.and(
-          new SubmittablePredicate(SubmitRecord.Status.OK),
-          Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
-          Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
+      if (!args.index.getSchema().hasField(ChangeField.IS_SUBMITTABLE)) {
+        // SubmittablePredicate will match if *any* of the submit records are OK,
+        // but we need to check that they're *all* OK, so check that none of the
+        // submit records match any of the negative cases. To avoid checking yet
+        // more negative cases for CLOSED and FORCED, instead make sure at least
+        // one submit record is OK.
+        return Predicate.and(
+            new SubmittablePredicate(SubmitRecord.Status.OK),
+            Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
+            Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
+      }
+      checkFieldAvailable(ChangeField.IS_SUBMITTABLE, "is:submittable");
+      return new IsSubmittablePredicate();
     }
 
     if ("ignored".equalsIgnoreCase(value)) {
@@ -742,30 +741,21 @@
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.STARTED)) {
-        return new BooleanPredicate(ChangeField.STARTED);
-      }
-      throw new QueryParseException(
-          "'is:started' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.STARTED, "is:started");
+      return new BooleanPredicate(ChangeField.STARTED);
     }
 
     if ("wip".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return new BooleanPredicate(ChangeField.WIP);
-      }
-      throw new QueryParseException("'is:wip' operator is not supported by change index version");
+      return new BooleanPredicate(ChangeField.WIP);
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.CHERRY_PICK)) {
-        return new BooleanPredicate(ChangeField.CHERRY_PICK);
-      }
-      throw new QueryParseException(
-          "'is:cherrypick' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.CHERRY_PICK, "is:cherrypick");
+      return new BooleanPredicate(ChangeField.CHERRY_PICK);
     }
 
     // for plugins the value will be operandName_pluginName
-    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
       ChangeIsOperandFactory op = args.isOperands.get(names.get(1), names.get(0));
       if (op != null) {
@@ -895,14 +885,21 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    if (!args.index.getSchema().hasField(ChangeField.FUZZY_HASHTAG)) {
-      throw new QueryParseException(
-          "'inhashtag' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
   @Operator
+  public Predicate<ChangeData> prefixhashtag(String hashtag) throws QueryParseException {
+    if (hashtag.isEmpty()) {
+      return ChangePredicates.hashtag(hashtag);
+    }
+
+    checkFieldAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+    return ChangePredicates.prefixHashtag(hashtag);
+  }
+
+  @Operator
   public Predicate<ChangeData> topic(String name) {
     return ChangePredicates.exactTopic(name);
   }
@@ -919,6 +916,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> prefixtopic(String name) throws QueryParseException {
+    if (name.isEmpty()) {
+      return ChangePredicates.exactTopic(name);
+    }
+
+    checkFieldAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+    return ChangePredicates.prefixTopic(name);
+  }
+
+  @Operator
   public Predicate<ChangeData> ref(String ref) throws QueryParseException {
     if (ref.startsWith("^")) {
       return new RegexRefPredicate(ref);
@@ -927,12 +934,18 @@
   }
 
   @Operator
-  public Predicate<ChangeData> f(String file) {
+  public Predicate<ChangeData> f(String file) throws QueryParseException {
     return file(file);
   }
 
+  /**
+   * Creates a predicate to match changes by file.
+   *
+   * @param file the value of the {@code file} query operator
+   * @throws QueryParseException thrown if parsing the value fails (may be thrown by subclasses)
+   */
   @Operator
-  public Predicate<ChangeData> file(String file) {
+  public Predicate<ChangeData> file(String file) throws QueryParseException {
     if (file.startsWith("^")) {
       return new RegexPathPredicate(file);
     }
@@ -948,54 +961,47 @@
   }
 
   @Operator
-  public Predicate<ChangeData> ext(String ext) throws QueryParseException {
+  public Predicate<ChangeData> ext(String ext) {
     return extension(ext);
   }
 
   @Operator
-  public Predicate<ChangeData> extension(String ext) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXTENSION)) {
-      return new FileExtensionPredicate(ext);
-    }
-    throw new QueryParseException("'extension' operator is not supported by change index version");
+  public Predicate<ChangeData> extension(String ext) {
+    return new FileExtensionPredicate(ext);
   }
 
   @Operator
-  public Predicate<ChangeData> onlyexts(String extList) throws QueryParseException {
+  public Predicate<ChangeData> onlyexts(String extList) {
     return onlyextensions(extList);
   }
 
   @Operator
-  public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.ONLY_EXTENSIONS)) {
-      return new FileExtensionListPredicate(extList);
-    }
-    throw new QueryParseException(
-        "'onlyextensions' operator is not supported by change index version");
+  public Predicate<ChangeData> onlyextensions(String extList) {
+    return new FileExtensionListPredicate(extList);
   }
 
   @Operator
-  public Predicate<ChangeData> footer(String footer) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.FOOTER)) {
-      return ChangePredicates.footer(footer);
-    }
-    throw new QueryParseException("'footer' operator is not supported by change index version");
+  public Predicate<ChangeData> footer(String footer) {
+    return ChangePredicates.footer(footer);
   }
 
   @Operator
-  public Predicate<ChangeData> dir(String directory) throws QueryParseException {
+  public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
+    checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    return ChangePredicates.hasFooter(footerName);
+  }
+
+  @Operator
+  public Predicate<ChangeData> dir(String directory) {
     return directory(directory);
   }
 
   @Operator
-  public Predicate<ChangeData> directory(String directory) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.DIRECTORY)) {
-      if (directory.startsWith("^")) {
-        return new RegexDirectoryPredicate(directory);
-      }
-      return ChangePredicates.directory(directory);
+  public Predicate<ChangeData> directory(String directory) {
+    if (directory.startsWith("^")) {
+      return new RegexDirectoryPredicate(directory);
     }
-    throw new QueryParseException("'directory' operator is not supported by change index version");
+    return ChangePredicates.directory(directory);
   }
 
   @Operator
@@ -1003,6 +1009,8 @@
       throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = null;
     AccountGroup.UUID group = null;
+    Integer count = null;
+    PredicateArgs.Operator countOp = null;
 
     // Parse for:
     // label:Code-Review=1,user=jsmith or
@@ -1013,24 +1021,48 @@
     // Special case: votes by owners can be tracked with ",owner":
     // label:Code-Review+2,owner
     // label:Code-Review+2,user=owner
-    List<String> splitReviewer = Lists.newArrayList(Splitter.on(',').limit(2).split(name));
+    // label:Code-Review+1,count=2
+    List<String> splitReviewer = LABEL_SPLITTER.limit(2).splitToList(name);
     name = splitReviewer.get(0); // remove all but the vote piece, e.g.'CodeReview=1'
 
     if (splitReviewer.size() == 2) {
       // process the user/group piece
       PredicateArgs lblArgs = new PredicateArgs(splitReviewer.get(1));
 
-      for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
-        if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
-          if (pair.getValue().equals(ARG_ID_OWNER)) {
+      // Disallow using the "count=" arg in conjunction with the "user=" or "group=" args. to avoid
+      // unnecessary complexity.
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_USER);
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_GROUP);
+
+      for (Map.Entry<String, ValOp> pair : lblArgs.keyValue.entrySet()) {
+        String key = pair.getKey();
+        String value = pair.getValue().value();
+        PredicateArgs.Operator operator = pair.getValue().operator();
+        if (key.equalsIgnoreCase(ARG_ID_USER)) {
+          if (value.equals(ARG_ID_OWNER)) {
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else if (pair.getValue().equals(ARG_ID_NON_UPLOADER)) {
+          } else if (value.equals(ARG_ID_NON_UPLOADER)) {
             accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
           } else {
-            accounts = parseAccount(pair.getValue());
+            accounts = parseAccount(value);
           }
-        } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
-          group = parseGroup(pair.getValue()).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_ID_GROUP)) {
+          group = parseGroup(value).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_COUNT)) {
+          if (!isInt(value)) {
+            throw new QueryParseException("Invalid count argument. Value should be an integer");
+          }
+          count = Integer.parseInt(value);
+          countOp = operator;
+          if (count == 0) {
+            throw new QueryParseException("Argument count=0 is not allowed.");
+          }
+          if (count > LabelPredicate.MAX_COUNT) {
+            throw new QueryParseException(
+                String.format(
+                    "count=%d is not allowed. Maximum allowed value for count is %d.",
+                    count, LabelPredicate.MAX_COUNT));
+          }
         } else {
           throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
         }
@@ -1067,7 +1099,7 @@
     // If the vote piece looks like Code-Review=NEED with a valid non-numeric
     // submit record status, interpret as a submit record query.
     int eq = name.indexOf('=');
-    if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
+    if (eq > 0) {
       String statusName = name.substring(eq + 1).toUpperCase();
       if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
         SubmitRecord.Label.Status status =
@@ -1079,7 +1111,18 @@
       }
     }
 
-    return new LabelPredicate(args, name, accounts, group);
+    return new LabelPredicate(args, name, accounts, group, count, countOp);
+  }
+
+  /** Assert that keys {@code k1} and {@code k2} do not exist in {@code labelArgs} together. */
+  private void assertDisjunctive(PredicateArgs labelArgs, String k1, String k2)
+      throws QueryParseException {
+    Map<String, ValOp> keyValArgs = labelArgs.keyValue;
+    if (keyValArgs.containsKey(k1) && keyValArgs.containsKey(k2)) {
+      throw new QueryParseException(
+          String.format(
+              "Cannot use the '%s' argument in conjunction with the '%s' argument", k1, k2));
+    }
   }
 
   private static boolean isInt(String s) {
@@ -1093,7 +1136,11 @@
   }
 
   @Operator
-  public Predicate<ChangeData> message(String text) {
+  public Predicate<ChangeData> message(String text) throws QueryParseException {
+    if (text.startsWith("^")) {
+      checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+      return new RegexMessagePredicate(text);
+    }
     return ChangePredicates.message(text);
   }
 
@@ -1109,15 +1156,29 @@
   }
 
   private Predicate<ChangeData> ignoredBySelf() throws QueryParseException {
-    return new StarPredicate(self(), StarredChangesUtil.IGNORE_LABEL);
+    return ChangePredicates.starBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.starredChangesUtil,
+        self(),
+        StarredChangesUtil.IGNORE_LABEL);
   }
 
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
-    return new StarPredicate(self(), StarredChangesUtil.DEFAULT_LABEL);
+    return ChangePredicates.starBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.starredChangesUtil,
+        self(),
+        StarredChangesUtil.DEFAULT_LABEL);
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
-    return ChangePredicates.draftBy(self());
+    return ChangePredicates.draftBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.commentsUtil,
+        self());
   }
 
   @Operator
@@ -1195,9 +1256,7 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-      throw new QueryParseException("'uploader' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.UPLOADER, "uploader");
     return uploader(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1212,10 +1271,7 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-      throw new QueryParseException(
-          "'attention' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
     return attention(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1260,9 +1316,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-      throw new QueryParseException("'uploader' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.UPLOADER, "uploaderin");
 
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
@@ -1307,10 +1361,7 @@
     if (Objects.equals(byState, Predicate.<ChangeData>any())) {
       return Predicate.any();
     }
-    if (args.getSchema().hasField(ChangeField.WIP)) {
-      return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
-    }
-    return byState;
+    return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
   }
 
   @Operator
@@ -1398,7 +1449,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1407,7 +1458,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1449,7 +1500,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1458,7 +1509,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1469,9 +1520,7 @@
         account = self();
       }
 
-      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
-      d.load(args.allUsersName, git);
-      Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
+      Set<BranchNameKey> destinations = getDestinationList(git, account).getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, value);
       }
@@ -1484,32 +1533,33 @@
     throw new QueryParseException("Unknown named destination: " + name);
   }
 
+  protected DestinationList getDestinationList(Repository git, Account.Id account)
+      throws ConfigInvalidException, RepositoryNotFoundException, IOException {
+    DestinationList dl = destinationListByAccount.get(account);
+    if (dl == null) {
+      dl = loadDestinationList(git, account);
+      destinationListByAccount.put(account, dl);
+    }
+    return dl;
+  }
+
+  protected DestinationList loadDestinationList(Repository git, Account.Id account)
+      throws ConfigInvalidException, RepositoryNotFoundException, IOException {
+    VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
+    d.load(args.allUsersName, git);
+    return d.getDestinationList();
+  }
+
   @Operator
   public Predicate<ChangeData> author(String who) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::author);
+    return getAuthorOrCommitterPredicate(
+        who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
   }
 
   @Operator
   public Predicate<ChangeData> committer(String who) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::committer);
-  }
-
-  @Operator
-  public Predicate<ChangeData> submittable(String str) throws QueryParseException {
-    SubmitRecord.Status status =
-        Enums.getIfPresent(SubmitRecord.Status.class, str.toUpperCase()).orNull();
-    if (status == null) {
-      throw error("invalid value for submittable:" + str);
-    }
-    return new SubmittablePredicate(status);
+    return getAuthorOrCommitterPredicate(
+        who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
   }
 
   @Operator
@@ -1522,41 +1572,31 @@
     if (value == null || Ints.tryParse(value) == null) {
       throw new QueryParseException("'revertof' must be an integer");
     }
-    if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
-      return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
-    }
-    throw new QueryParseException("'revertof' operator is not supported by change index version");
+    return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
   }
 
   @Operator
-  public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.SUBMISSIONID)) {
-      return ChangePredicates.submissionId(value);
-    }
-    throw new QueryParseException(
-        "'submissionid' operator is not supported by change index version");
+  public Predicate<ChangeData> submissionId(String value) {
+    return ChangePredicates.submissionId(value);
   }
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_CHANGE)
-        && args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_PATCHSET)) {
-      if (Ints.tryParse(value) != null) {
-        return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
-      }
-      try {
-        PatchSet.Id patchSetId = PatchSet.Id.parse(value);
-        return ChangePredicates.cherryPickOf(patchSetId);
-      } catch (IllegalArgumentException e) {
-        throw new QueryParseException(
-            "'"
-                + value
-                + "' is not a valid input. It must be in the 'ChangeNumber[,PatchsetNumber]' format.",
-            e);
-      }
+    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    if (Ints.tryParse(value) != null) {
+      return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
-    throw new QueryParseException(
-        "'cherrypickof' operator is not supported by change index version");
+    try {
+      PatchSet.Id patchSetId = PatchSet.Id.parse(value);
+      return ChangePredicates.cherryPickOf(patchSetId);
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(
+          "'"
+              + value
+              + "' is not a valid input. It must be in the 'ChangeNumber[,PatchsetNumber]' format.",
+          e);
+    }
   }
 
   @Override
@@ -1615,6 +1655,14 @@
     return Predicate.or(predicates);
   }
 
+  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    if (!args.index.getSchema().hasField(field)) {
+      throw new QueryParseException(
+          String.format("'%s' operator is not supported by change index version", operator));
+    }
+  }
+
   private Predicate<ChangeData> getAuthorOrCommitterPredicate(
       String who,
       Function<String, Predicate<ChangeData>> exactPredicateFunc,
@@ -1729,11 +1777,9 @@
       String who, ReviewerStateInternal state, boolean forDefaultField)
       throws QueryParseException, IOException, ConfigInvalidException {
     Predicate<ChangeData> reviewerByEmailPredicate = null;
-    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
-      Address address = Address.tryParse(who);
-      if (address != null) {
-        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
-      }
+    Address address = Address.tryParse(who);
+    if (address != null) {
+      reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
     }
 
     Predicate<ChangeData> reviewerPredicate = null;
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 7d6b1ed..66c136f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Change.Status;
 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.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.List;
@@ -76,7 +77,7 @@
     return status.name().toLowerCase();
   }
 
-  public static Predicate<ChangeData> parse(String value) {
+  public static Predicate<ChangeData> parse(String value) throws QueryParseException {
     String lower = value.toLowerCase();
     NavigableMap<String, Predicate<ChangeData>> head = PREDICATES.tailMap(lower, true);
     if (!head.isEmpty()) {
@@ -86,7 +87,7 @@
         return e.getValue();
       }
     }
-    return NONE;
+    throw new QueryParseException("Unrecognized value: " + value);
   }
 
   public static Predicate<ChangeData> open() {
diff --git a/java/com/google/gerrit/server/query/change/ConstantPredicate.java b/java/com/google/gerrit/server/query/change/ConstantPredicate.java
new file mode 100644
index 0000000..f0a85fe
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ConstantPredicate.java
@@ -0,0 +1,38 @@
+// 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.query.change;
+
+import com.google.inject.Singleton;
+
+/**
+ * A submit requirement predicate (can only be used in submit requirement expressions) that always
+ * evaluates to {@code true} if the value is equal to "true" or false otherwise.
+ */
+@Singleton
+public class ConstantPredicate extends SubmitRequirementPredicate {
+  public ConstantPredicate(String value) {
+    super("is", value);
+  }
+
+  @Override
+  public boolean match(ChangeData object) {
+    return "true".equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
new file mode 100644
index 0000000..5a51f5d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
@@ -0,0 +1,124 @@
+// 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.query.change;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A submit requirement predicate that allows checking for distinct voters across labels.
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ *   <li>[Label-Name1,Label-Name2],value=MAX,count=2
+ *   <li>[Label-Name1,Label-Name2,Label-Name3],count=5
+ * </ul>
+ */
+public class DistinctVotersPredicate extends SubmitRequirementPredicate {
+  public interface Factory {
+    DistinctVotersPredicate create(String value) throws QueryParseException;
+  }
+
+  private static final Pattern PATTERN =
+      Pattern.compile(
+          "\\[(?<labels>[^\\]]+)\\](,value=(?<value>MAX|MIN|-?[0-9]+))?,count\\>(?<count>[0-9]+)");
+
+  private final ProjectCache projectCache;
+  private final ImmutableList<String> labelNames;
+  private final boolean enforceMaxVote;
+  private final boolean enforceMinVote;
+  private final Optional<Integer> enforceIntegerVote;
+  private final int numDistinctVotes;
+
+  @Inject
+  public DistinctVotersPredicate(ProjectCache projectCache, @Assisted String value)
+      throws QueryParseException {
+    super("distinctvoters", value);
+    this.projectCache = projectCache;
+    Matcher m = PATTERN.matcher(value);
+    if (!m.matches()) {
+      throw new QueryParseException("input " + value + " invalid");
+    }
+    labelNames = ImmutableList.copyOf(Splitter.on(',').split(m.group("labels")));
+    Integer votes = Ints.tryParse(m.group("count"));
+    if (votes == null) {
+      throw new QueryParseException("unable to parse number of required votes");
+    }
+    numDistinctVotes = votes + 1; // Regex has > sign
+    if (m.group("value") != null) {
+      enforceMaxVote = "MAX".equals(m.group("value"));
+      enforceMinVote = "MIN".equals(m.group("value"));
+      enforceIntegerVote = Optional.ofNullable(Ints.tryParse(m.group("value")));
+    } else {
+      enforceMaxVote = false;
+      enforceMinVote = false;
+      enforceIntegerVote = Optional.empty();
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    ProjectState projectState =
+        projectCache
+            .get(cd.project())
+            .orElseThrow(() -> new IllegalStateException("project absent " + cd.project()));
+    return cd.currentApprovals().stream()
+            .filter(psa -> filterPatchSetApproval(psa, projectState))
+            .map(PatchSetApproval::accountId)
+            .distinct()
+            .count()
+        >= numDistinctVotes;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  private boolean filterPatchSetApproval(PatchSetApproval psa, ProjectState projectState) {
+    if (!labelNames.contains(psa.label())) {
+      return false;
+    }
+
+    Optional<LabelType> labelType = projectState.getLabelTypes().byLabel(psa.labelId());
+    if (labelType.isEmpty()) {
+      // Label is not configured in this project
+      return false;
+    }
+
+    if (enforceMaxVote && psa.value() != labelType.get().getMaxPositive()) {
+      return false;
+    }
+    if (enforceMinVote && psa.value() != labelType.get().getMaxNegative()) {
+      return false;
+    }
+    if (enforceIntegerVote.isPresent() && psa.value() != enforceIntegerVote.get()) {
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 4a0b649..6aacfc9 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -28,23 +29,41 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
 import java.util.Optional;
 
 public class EqualsLabelPredicate extends ChangeIndexPostFilterPredicate {
   protected final ProjectCache projectCache;
   protected final PermissionBackend permissionBackend;
   protected final IdentifiedUser.GenericFactory userFactory;
+  /** label name to be matched. */
   protected final String label;
+
+  /** Expected vote value for the label. */
   protected final int expVal;
+
+  /**
+   * Number of times the value {@link #expVal} for label {@link #label} should occur. If null, match
+   * with any count greater or equal to 1.
+   */
+  @Nullable protected final Integer count;
+
+  /** Account ID that has voted on the label. */
   protected final Account.Id account;
+
   protected final AccountGroup.UUID group;
 
   public EqualsLabelPredicate(
-      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+      LabelPredicate.Args args,
+      String label,
+      int expVal,
+      Account.Id account,
+      @Nullable Integer count) {
+    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account, count));
     this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
+    this.count = count;
     this.group = args.group;
     this.label = label;
     this.expVal = expVal;
@@ -60,6 +79,14 @@
       return false;
     }
 
+    if (Integer.valueOf(0).equals(count)) {
+      // We don't match against count=0 so that the computation is identical to the stored values
+      // in the index. We do that since computing count=0 requires looping on all {label_type,
+      // vote_value} for the change and storing a {count=0} format for it in the change index which
+      // is computationally expensive.
+      return false;
+    }
+
     Optional<ProjectState> project = projectCache.get(c.getDest().project());
     if (!project.isPresent()) {
       // The project has disappeared.
@@ -73,21 +100,23 @@
     }
 
     boolean hasVote = false;
+    int matchingVotes = 0;
+    StorageConstraint currentStorageConstraint = object.getStorageConstraint();
     object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
         if (match(object, p.value(), p.accountId())) {
-          return true;
+          matchingVotes += 1;
         }
       }
     }
-
+    object.setStorageConstraint(currentStorageConstraint);
     if (!hasVote && expVal == 0) {
       return true;
     }
 
-    return false;
+    return count == null ? matchingVotes >= 1 : matchingVotes == count;
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index c8e9f66..e7b25fb 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -22,6 +22,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
@@ -294,7 +295,7 @@
     return and(project(project), or(groupPredicates));
   }
 
-  public static List<ChangeData> byProjectGroups(
+  public static ImmutableList<ChangeData> byProjectGroups(
       Provider<InternalChangeQuery> queryProvider,
       IndexConfig indexConfig,
       Project.NameKey project,
@@ -314,7 +315,7 @@
           querySupplier, byProjectGroupsPredicate(indexConfig, project, groups));
     }
     Set<Change.Id> seen = new HashSet<>();
-    List<ChangeData> result = new ArrayList<>();
+    ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
           queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
@@ -323,6 +324,6 @@
         }
       }
     }
-    return result;
+    return result.build();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
new file mode 100644
index 0000000..17de132
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
@@ -0,0 +1,65 @@
+// 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.query.change;
+
+import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class IsSubmittablePredicate extends BooleanPredicate {
+  public IsSubmittablePredicate() {
+    super(ChangeField.IS_SUBMITTABLE);
+  }
+
+  /**
+   * Rewrite the is:submittable predicate.
+   *
+   * <p>If we run a query with "is:submittable OR -is:submittable" the result should match all
+   * changes. In Lucene, we keep separate sub-indexes for open and closed changes. The Lucene
+   * backend inspects the input predicate and depending on all its child predicates decides if the
+   * query should run against the open sub-index, closed sub-index or both.
+   *
+   * <p>The "is:submittable" operator is implemented as:
+   *
+   * <p>issubmittable:1
+   *
+   * <p>But we want to exclude closed changes from being matched by this query. For the normal case,
+   * we rewrite the query as:
+   *
+   * <p>issubmittable:1 AND status:new
+   *
+   * <p>Hence Lucene will match the query against the open sub-index. For the negated case (i.e.
+   * "-is:submittable"), we cannot just negate the previous query because it would result in:
+   *
+   * <p>-(issubmittable:1 AND status:new)
+   *
+   * <p>Lucene will conclude that it should look for changes that are <b>not</b> new and hence will
+   * run the query against the closed sub-index, not matching with changes that are open but not
+   * submittable. For this case, we need to rewrite the query to match with closed changes <b>or</b>
+   * changes that are not submittable.
+   */
+  public static Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
+    if (in instanceof IsSubmittablePredicate) {
+      return Predicate.and(
+          new BooleanPredicate(ChangeField.IS_SUBMITTABLE), ChangeStatusPredicate.open());
+    }
+    if (in instanceof NotPredicate && in.getChild(0) instanceof IsSubmittablePredicate) {
+      return Predicate.or(
+          Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE)),
+          ChangeStatusPredicate.closed());
+    }
+    return in;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 5f017fb..2a5a47d 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.query.OrPredicate;
@@ -29,9 +29,11 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.IntStream;
 
 public class LabelPredicate extends OrPredicate<ChangeData> {
   protected static final int MAX_LABEL_VALUE = 4;
+  protected static final int MAX_COUNT = 5; // inclusive
 
   protected static class Args {
     protected final ProjectCache projectCache;
@@ -40,6 +42,8 @@
     protected final String value;
     protected final Set<Account.Id> accounts;
     protected final AccountGroup.UUID group;
+    protected final Integer count;
+    protected final PredicateArgs.Operator countOp;
 
     protected Args(
         ProjectCache projectCache,
@@ -47,13 +51,17 @@
         IdentifiedUser.GenericFactory userFactory,
         String value,
         Set<Account.Id> accounts,
-        AccountGroup.UUID group) {
+        AccountGroup.UUID group,
+        @Nullable Integer count,
+        @Nullable PredicateArgs.Operator countOp) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.userFactory = userFactory;
       this.value = value;
       this.accounts = accounts;
       this.group = group;
+      this.count = count;
+      this.countOp = countOp;
     }
   }
 
@@ -75,19 +83,35 @@
       ChangeQueryBuilder.Arguments a,
       String value,
       Set<Account.Id> accounts,
-      AccountGroup.UUID group) {
+      AccountGroup.UUID group,
+      @Nullable Integer count,
+      @Nullable PredicateArgs.Operator countOp) {
     super(
         predicates(
-            new Args(a.projectCache, a.permissionBackend, a.userFactory, value, accounts, group)));
+            new Args(
+                a.projectCache,
+                a.permissionBackend,
+                a.userFactory,
+                value,
+                accounts,
+                group,
+                count,
+                countOp)));
     this.value = value;
   }
 
   protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
-
+    List<Integer> counts = getCounts(args.count, args.countOp);
     try {
       MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
-      return ImmutableList.of(magicLabelPredicate(args, mlv));
+      List<Predicate<ChangeData>> result = Lists.newArrayListWithCapacity(counts.size());
+      if (counts.isEmpty()) {
+        result.add(magicLabelPredicate(args, mlv, /* count= */ null));
+      } else {
+        counts.forEach(count -> result.add(magicLabelPredicate(args, mlv, count)));
+      }
+      return result;
     } catch (IllegalArgumentException e) {
       // Try next format.
     }
@@ -123,16 +147,24 @@
     int min = range.min;
     int max = range.max;
 
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(max - min + 1);
+    List<Predicate<ChangeData>> r =
+        Lists.newArrayListWithCapacity((counts.isEmpty() ? 1 : counts.size()) * (max - min + 1));
     for (int i = min; i <= max; i++) {
-      r.add(onePredicate(args, prefix, i));
+      if (counts.isEmpty()) {
+        r.add(onePredicate(args, prefix, i, /* count= */ null));
+      } else {
+        for (int count : counts) {
+          r.add(onePredicate(args, prefix, i, count));
+        }
+      }
     }
     return r;
   }
 
-  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> onePredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (expVal != 0) {
-      return equalsLabelPredicate(args, label, expVal);
+      return equalsLabelPredicate(args, label, expVal, count);
     }
     return noLabelQuery(args, label);
   }
@@ -140,34 +172,69 @@
   protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
     List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
-      r.add(equalsLabelPredicate(args, label, i));
-      r.add(equalsLabelPredicate(args, label, -i));
+      r.add(equalsLabelPredicate(args, label, i, /* count= */ null));
+      r.add(equalsLabelPredicate(args, label, -i, /* count= */ null));
     }
     return not(or(r));
   }
 
-  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> equalsLabelPredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new EqualsLabelPredicate(args, label, expVal, null);
+      return new EqualsLabelPredicate(args, label, expVal, null, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new EqualsLabelPredicate(args, label, expVal, a));
+      r.add(new EqualsLabelPredicate(args, label, expVal, a, count));
     }
     return or(r);
   }
 
-  protected static Predicate<ChangeData> magicLabelPredicate(Args args, MagicLabelVote mlv) {
+  protected static Predicate<ChangeData> magicLabelPredicate(
+      Args args, MagicLabelVote mlv, @Nullable Integer count) {
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new MagicLabelPredicate(args, mlv, /* account= */ null);
+      return new MagicLabelPredicate(args, mlv, /* account= */ null, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new MagicLabelPredicate(args, mlv, a));
+      r.add(new MagicLabelPredicate(args, mlv, a, count));
     }
     return or(r);
   }
 
+  private static List<Integer> getCounts(
+      @Nullable Integer count, @Nullable PredicateArgs.Operator countOp) {
+    List<Integer> result = new ArrayList<>();
+    if (count == null) {
+      return result;
+    }
+    switch (countOp) {
+      case EQUAL:
+      case GREATER_EQUAL:
+      case LESS_EQUAL:
+        result.add(count);
+        break;
+      case GREATER:
+      case LESS:
+      default:
+        break;
+    }
+    switch (countOp) {
+      case GREATER:
+      case GREATER_EQUAL:
+        IntStream.range(count + 1, MAX_COUNT + 1).forEach(result::add);
+        break;
+      case LESS:
+      case LESS_EQUAL:
+        IntStream.range(0, count).forEach(result::add);
+        break;
+      case EQUAL:
+      default:
+        break;
+    }
+    return result;
+  }
+
   @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_LABEL + ":" + value;
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
index 3917c79..5a81ca1 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -30,13 +31,21 @@
   protected final LabelPredicate.Args args;
   private final MagicLabelVote magicLabelVote;
   private final Account.Id account;
+  @Nullable private final Integer count;
 
   public MagicLabelPredicate(
-      LabelPredicate.Args args, MagicLabelVote magicLabelVote, Account.Id account) {
-    super(ChangeField.LABEL, magicLabelVote.formatLabel());
+      LabelPredicate.Args args,
+      MagicLabelVote magicLabelVote,
+      Account.Id account,
+      @Nullable Integer count) {
+    super(
+        ChangeField.LABEL,
+        ChangeField.formatLabel(
+            magicLabelVote.label(), magicLabelVote.value().name(), account, count));
     this.account = account;
     this.args = args;
     this.magicLabelVote = magicLabelVote;
+    this.count = count;
   }
 
   @Override
@@ -87,7 +96,7 @@
   }
 
   private EqualsLabelPredicate numericPredicate(String label, short value) {
-    return new EqualsLabelPredicate(args, label, value, account);
+    return new EqualsLabelPredicate(args, label, value, account, count);
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index 19df3cd..f2de536 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.ResultSet;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -50,7 +49,7 @@
         getChildren().stream().map(p -> ((ChangeDataSource) p).read()).collect(toImmutableList());
     return new LazyResultSet<>(
         () -> {
-          List<ChangeData> r = new ArrayList<>();
+          ImmutableList.Builder<ChangeData> r = ImmutableList.builder();
           Set<Change.Id> have = new HashSet<>();
           for (ResultSet<ChangeData> resultSet : results) {
             for (ChangeData result : resultSet) {
@@ -59,7 +58,7 @@
               }
             }
           }
-          return ImmutableList.copyOf(r);
+          return r.build();
         });
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 4a54c03..536aae2 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -23,9 +24,6 @@
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import java.util.Optional;
 
 public class ParentProjectPredicate extends OrPredicate<ChangeData> {
@@ -39,14 +37,14 @@
     this.value = value;
   }
 
-  protected static List<Predicate<ChangeData>> predicates(
+  protected static ImmutableList<Predicate<ChangeData>> predicates(
       ProjectCache projectCache, ChildProjects childProjects, String value) {
     Optional<ProjectState> projectState = projectCache.get(Project.nameKey(value));
     if (!projectState.isPresent()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<Predicate<ChangeData>> r = new ArrayList<>();
+    ImmutableList.Builder<Predicate<ChangeData>> r = ImmutableList.builder();
     r.add(ChangePredicates.project(projectState.get().getNameKey()));
     try {
       for (ProjectInfo p : childProjects.list(projectState.get().getNameKey())) {
@@ -55,7 +53,7 @@
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log("cannot check permissions to expand child projects");
     }
-    return r;
+    return r.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index d82b9bc..ebe4390 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * This class is used to extract comma separated values in a predicate.
@@ -30,8 +33,35 @@
  * appear in the map and others in the positional list (e.g. "vote=approved,jb_2.3).
  */
 public class PredicateArgs {
+  private static final Pattern SPLIT_PATTERN = Pattern.compile("(>|>=|=|<|<=)([^=].*)$");
+
   public List<String> positional;
-  public Map<String, String> keyValue;
+  public Map<String, ValOp> keyValue;
+
+  enum Operator {
+    EQUAL("="),
+    GREATER_EQUAL(">="),
+    GREATER(">"),
+    LESS_EQUAL("<="),
+    LESS("<");
+
+    final String op;
+
+    Operator(String op) {
+      this.op = op;
+    }
+  }
+
+  @AutoValue
+  public abstract static class ValOp {
+    abstract String value();
+
+    abstract Operator operator();
+
+    static ValOp create(String value, Operator operator) {
+      return new AutoValue_PredicateArgs_ValOp(value, operator);
+    }
+  }
 
   /**
    * Parses query arguments into {@link #keyValue} and/or {@link #positional}..
@@ -46,19 +76,39 @@
     keyValue = new HashMap<>();
 
     for (String arg : Splitter.on(',').split(args)) {
-      List<String> splitKeyValue = Splitter.on('=').splitToList(arg);
+      Matcher m = SPLIT_PATTERN.matcher(arg);
 
-      if (splitKeyValue.size() == 1) {
-        positional.add(splitKeyValue.get(0));
-      } else if (splitKeyValue.size() == 2) {
-        if (!keyValue.containsKey(splitKeyValue.get(0))) {
-          keyValue.put(splitKeyValue.get(0), splitKeyValue.get(1));
+      if (!m.find()) {
+        positional.add(arg);
+      } else if (m.groupCount() == 2) {
+        String key = arg.substring(0, m.start());
+        String op = m.group(1);
+        String val = m.group(2);
+        if (!keyValue.containsKey(key)) {
+          keyValue.put(key, ValOp.create(val, getOperator(op)));
         } else {
-          throw new QueryParseException("Duplicate key " + splitKeyValue.get(0));
+          throw new QueryParseException("Duplicate key " + key);
         }
       } else {
-        throw new QueryParseException("invalid arg " + arg);
+        throw new QueryParseException("Invalid arg " + arg);
       }
     }
   }
+
+  private Operator getOperator(String operator) {
+    switch (operator) {
+      case "<":
+        return Operator.LESS;
+      case "<=":
+        return Operator.LESS_EQUAL;
+      case "=":
+        return Operator.EQUAL;
+      case ">=":
+        return Operator.GREATER_EQUAL;
+      case ">":
+        return Operator.GREATER;
+      default:
+        throw new IllegalArgumentException("Invalid Operator " + operator);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
new file mode 100644
index 0000000..22891bc
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
@@ -0,0 +1,55 @@
+// 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.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+/**
+ * A submit requirement predicate that matches with changes having the author email's address
+ * matching a specific regular expression pattern.
+ */
+public class RegexAuthorEmailPredicate extends SubmitRequirementPredicate {
+  protected final RunAutomaton authorEmailPattern;
+
+  public RegexAuthorEmailPredicate(String pattern) throws QueryParseException {
+    super("authoremail", pattern);
+
+    if (pattern.startsWith("^")) {
+      pattern = pattern.substring(1);
+    }
+
+    if (pattern.endsWith("$") && !pattern.endsWith("\\$")) {
+      pattern = pattern.substring(0, pattern.length() - 1);
+    }
+
+    try {
+      this.authorEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return authorEmailPattern.run(cd.getAuthor().getEmailAddress());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java
new file mode 100644
index 0000000..a2b9ad8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.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.server.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexMessagePredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexMessagePredicate(String re) throws QueryParseException {
+    super(ChangeField.COMMIT_MESSAGE_EXACT, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    try {
+      this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", re), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return pattern.run(cd.commitMessage());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index e63714f..2580a1b 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -14,8 +14,16 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.FileEditsPredicate;
+import com.google.gerrit.server.query.FileEditsPredicate.FileEditsArgs;
 import com.google.inject.Inject;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 
 /**
  * A query builder for submit requirement expressions that includes all {@link ChangeQueryBuilder}
@@ -28,8 +36,96 @@
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> def =
       new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
 
+  private final DistinctVotersPredicate.Factory distinctVotersPredicateFactory;
+
+  /**
+   * Regular expression for the {@link #file(String)} operator. Field value is of the form:
+   *
+   * <p>'$fileRegex',withDiffContaining='$contentRegex'
+   *
+   * <p>Both $fileRegex and $contentRegex may contain escaped single or double quotes.
+   */
+  private static final Pattern FILE_EDITS_PATTERN =
+      Pattern.compile("'((?:(?:\\\\')|(?:[^']))*)',withDiffContaining='((?:(?:\\\\')|(?:[^']))*)'");
+
+  private final FileEditsPredicate.Factory fileEditsPredicateFactory;
+
   @Inject
-  SubmitRequirementChangeQueryBuilder(Arguments args) {
+  SubmitRequirementChangeQueryBuilder(
+      Arguments args,
+      DistinctVotersPredicate.Factory distinctVotersPredicateFactory,
+      FileEditsPredicate.Factory fileEditsPredicateFactory) {
     super(def, args);
+    this.distinctVotersPredicateFactory = distinctVotersPredicateFactory;
+    this.fileEditsPredicateFactory = fileEditsPredicateFactory;
+  }
+
+  @Override
+  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator) {
+    // Submit requirements don't rely on the index, so they can be used regardless of index schema
+    // version.
+  }
+
+  @Override
+  public Predicate<ChangeData> is(String value) throws QueryParseException {
+    if ("submittable".equalsIgnoreCase(value)) {
+      throw new QueryParseException(
+          String.format(
+              "Operator 'is:submittable' cannot be used in submit requirement expressions."));
+    }
+    if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) {
+      return new ConstantPredicate(value);
+    }
+    return super.is(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> authoremail(String who) throws QueryParseException {
+    return new RegexAuthorEmailPredicate(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> distinctvoters(String value) throws QueryParseException {
+    return distinctVotersPredicateFactory.create(value);
+  }
+
+  /**
+   * A SR operator that can match with file path and content pattern. The value should be of the
+   * form:
+   *
+   * <p>file:"'$filePattern',withDiffContaining='$contentPattern'"
+   *
+   * <p>The operator matches with changes that have their latest PS vs. base diff containing a file
+   * path matching the {@code filePattern} with an edit (added, deleted, modified) matching the
+   * {@code contentPattern}. {@code filePattern} and {@code contentPattern} can start with "^" to
+   * use regular expression matching.
+   *
+   * <p>If the specified value does not match this form, we fall back to the operator's
+   * implementation in {@link ChangeQueryBuilder}.
+   */
+  @Override
+  public Predicate<ChangeData> file(String value) throws QueryParseException {
+    Matcher matcher = FILE_EDITS_PATTERN.matcher(value);
+    if (!matcher.find()) {
+      return super.file(value);
+    }
+    String filePattern = matcher.group(1);
+    String contentPattern = matcher.group(2);
+    if (filePattern.startsWith("^")) {
+      validateRegularExpression(filePattern, "Invalid file pattern.");
+    }
+    if (contentPattern.startsWith("^")) {
+      validateRegularExpression(contentPattern, "Invalid content pattern.");
+    }
+    return fileEditsPredicateFactory.create(FileEditsArgs.create(filePattern, contentPattern));
+  }
+
+  private static void validateRegularExpression(String pattern, String errorMessage)
+      throws QueryParseException {
+    try {
+      Pattern.compile(pattern);
+    } catch (PatternSyntaxException e) {
+      throw new QueryParseException(errorMessage, e);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 63817d2..2566b72 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -34,7 +34,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/server/restapi/access/AccessResource.java b/java/com/google/gerrit/server/restapi/access/AccessResource.java
index 4847da4..36ffdcf 100644
--- a/java/com/google/gerrit/server/restapi/access/AccessResource.java
+++ b/java/com/google/gerrit/server/restapi/access/AccessResource.java
@@ -26,6 +26,5 @@
  * collection.
  */
 public class AccessResource implements RestResource {
-  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND =
-      new TypeLiteral<RestView<AccessResource>>() {};
+  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/restapi/account/Capabilities.java b/java/com/google/gerrit/server/restapi/account/Capabilities.java
index 3d719ff9..469f05d 100644
--- a/java/com/google/gerrit/server/restapi/account/Capabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/Capabilities.java
@@ -71,12 +71,10 @@
     }
 
     GlobalOrPluginPermission perm = parse(id);
-    try {
-      permissionBackend.absentUser(target.getAccountId()).check(perm);
+    if (permissionBackend.absentUser(target.getAccountId()).test(perm)) {
       return new AccountResource.Capability(target, globalOrPluginPermissionName(perm));
-    } catch (AuthException e) {
-      throw new ResourceNotFoundException(id, e);
     }
+    throw new ResourceNotFoundException(id);
   }
 
   private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 1485a6e56..fbd99eb 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -39,6 +40,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
@@ -54,7 +56,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
@@ -75,6 +77,7 @@
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
+  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   DeleteDraftComments(
@@ -86,7 +89,8 @@
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      ExperimentFeatures experimentFeatures) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
     this.queryBuilderProvider = queryBuilderProvider;
@@ -96,6 +100,7 @@
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
+    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -118,7 +123,7 @@
     HumanCommentFormatter humanCommentFormatter =
         commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = rsrc.getUser().getAccountId();
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
     List<Op> ops = new ArrayList<>();
     for (ChangeData cd :
@@ -146,7 +151,12 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(accountId);
+    Predicate<ChangeData> hasDraft =
+        ChangePredicates.draftBy(
+            experimentFeatures.isFeatureEnabled(
+                GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+            commentsUtil,
+            accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatar.java b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
index 5256d68..14fee12 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAvatar.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
@@ -55,12 +55,12 @@
   public Response.Redirect apply(AccountResource rsrc) throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
+      throw new ResourceNotFoundException().caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
     }
 
     String url = impl.getUrl(rsrc.getUser(), size);
     if (Strings.isNullOrEmpty(url)) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
+      throw new ResourceNotFoundException().caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
     }
     return Response.redirect(url);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
index 1091599..ba7a37f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -48,7 +48,7 @@
   public Response<AccountDetailInfo> apply(AccountResource rsrc) throws PermissionBackendException {
     Account a = rsrc.getUser().getAccount();
     AccountDetailInfo info = new AccountDetailInfo(a.id().get());
-    info.registeredOn = a.registeredOn();
+    info.setRegisteredOn(a.registeredOn());
     info.inactive = !a.isActive() ? true : null;
     directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
     return Response.ok(info);
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index aee0b78..9a11891 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
@@ -92,70 +93,75 @@
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
     AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Set Preferred Email via API",
-            user.getAccountId(),
-            (a, u) -> {
-              if (preferredEmail.equals(a.account().preferredEmail())) {
-                alreadyPreferred.set(true);
-              } else {
-                // check if the user has a matching email
-                String matchingEmail = null;
-                for (String email :
-                    a.externalIds().stream()
-                        .map(ExternalId::email)
-                        .filter(Objects::nonNull)
-                        .collect(toSet())) {
-                  if (email.equals(preferredEmail)) {
-                    // we have an email that matches exactly, prefer this one
-                    matchingEmail = email;
-                    break;
-                  } else if (matchingEmail == null && email.equalsIgnoreCase(preferredEmail)) {
-                    // we found an email that matches but has a different case
-                    matchingEmail = email;
-                  }
-                }
-
-                if (matchingEmail == null) {
-                  // user doesn't have an external ID for this email
-                  if (user.hasEmailAddress(preferredEmail)) {
-                    // but Realm says the user is allowed to use this email
-                    Set<ExternalId> existingExtIdsWithThisEmail =
-                        externalIds.byEmail(preferredEmail);
-                    if (!existingExtIdsWithThisEmail.isEmpty()) {
-                      // but the email is already assigned to another account
-                      logger.atWarning().log(
-                          "Cannot set preferred email %s for account %s because it is owned"
-                              + " by the following account(s): %s",
-                          preferredEmail,
-                          user.getAccountId(),
-                          existingExtIdsWithThisEmail.stream()
-                              .map(ExternalId::accountId)
-                              .collect(toList()));
-                      exception.set(
-                          Optional.of(
-                              new ResourceConflictException("email in use by another account")));
-                      return;
+    Optional<AccountState> updatedAccount =
+        accountsUpdateProvider
+            .get()
+            .update(
+                "Set Preferred Email via API",
+                user.getAccountId(),
+                (a, u) -> {
+                  if (preferredEmail.equals(a.account().preferredEmail())) {
+                    alreadyPreferred.set(true);
+                  } else {
+                    // check if the user has a matching email
+                    String matchingEmail = null;
+                    for (String email :
+                        a.externalIds().stream()
+                            .map(ExternalId::email)
+                            .filter(Objects::nonNull)
+                            .collect(toSet())) {
+                      if (email.equals(preferredEmail)) {
+                        // we have an email that matches exactly, prefer this one
+                        matchingEmail = email;
+                        break;
+                      } else if (matchingEmail == null && email.equalsIgnoreCase(preferredEmail)) {
+                        // we found an email that matches but has a different case
+                        matchingEmail = email;
+                      }
                     }
 
-                    // claim the email now
-                    u.addExternalId(
-                        externalIdFactory.createEmail(a.account().id(), preferredEmail));
-                    matchingEmail = preferredEmail;
-                  } else {
-                    // Realm says that the email doesn't belong to the user. This can only happen as
-                    // a race condition because EmailsCollection would have thrown
-                    // ResourceNotFoundException already before invoking this REST endpoint.
-                    exception.set(Optional.of(new ResourceNotFoundException(preferredEmail)));
-                    return;
+                    if (matchingEmail == null) {
+                      // user doesn't have an external ID for this email
+                      if (user.hasEmailAddress(preferredEmail)) {
+                        // but Realm says the user is allowed to use this email
+                        Set<ExternalId> existingExtIdsWithThisEmail =
+                            externalIds.byEmail(preferredEmail);
+                        if (!existingExtIdsWithThisEmail.isEmpty()) {
+                          // but the email is already assigned to another account
+                          logger.atWarning().log(
+                              "Cannot set preferred email %s for account %s because it is owned"
+                                  + " by the following account(s): %s",
+                              preferredEmail,
+                              user.getAccountId(),
+                              existingExtIdsWithThisEmail.stream()
+                                  .map(ExternalId::accountId)
+                                  .collect(toList()));
+                          exception.set(
+                              Optional.of(
+                                  new ResourceConflictException(
+                                      "email in use by another account")));
+                          return;
+                        }
+
+                        // claim the email now
+                        u.addExternalId(
+                            externalIdFactory.createEmail(a.account().id(), preferredEmail));
+                        matchingEmail = preferredEmail;
+                      } else {
+                        // Realm says that the email doesn't belong to the user. This can only
+                        // happen as
+                        // a race condition because EmailsCollection would have thrown
+                        // ResourceNotFoundException already before invoking this REST endpoint.
+                        exception.set(Optional.of(new ResourceNotFoundException(preferredEmail)));
+                        return;
+                      }
+                    }
+                    u.setPreferredEmail(matchingEmail);
                   }
-                }
-                u.setPreferredEmail(matchingEmail);
-              }
-            })
-        .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+                });
+    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/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index e6b4eee..c671562 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -186,11 +185,8 @@
       if (modifyAccountCapabilityChecked) {
         fillOptions.add(FillOptions.SECONDARY_EMAILS);
       } else {
-        try {
-          permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+        if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
           fillOptions.add(FillOptions.SECONDARY_EMAILS);
-        } catch (AuthException e) {
-          // Do nothing.
         }
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 2cfc3f5..8dd0e78 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -30,8 +30,10 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -43,24 +45,30 @@
 @Singleton
 public class Abandon
     implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
+  private final ChangeData.Factory changeDataFactory;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
   private final NotifyResolver notifyResolver;
   private final PatchSetUtil patchSetUtil;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   @Inject
   Abandon(
+      ChangeData.Factory changeDataFactory,
       BatchUpdate.Factory updateFactory,
       ChangeJson.Factory json,
       AbandonOp.Factory abandonOpFactory,
       NotifyResolver notifyResolver,
-      PatchSetUtil patchSetUtil) {
+      PatchSetUtil patchSetUtil,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+    this.changeDataFactory = changeDataFactory;
     this.updateFactory = updateFactory;
     this.json = json;
     this.abandonOpFactory = abandonOpFactory;
     this.notifyResolver = notifyResolver;
     this.patchSetUtil = patchSetUtil;
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   @Override
@@ -117,9 +125,15 @@
       throws RestApiException, UpdateException {
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
     AbandonOp op = abandonOpFactory.create(accountState, msgTxt);
-    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.nowTs())) {
+    ChangeData changeData = changeDataFactory.create(notes.getProjectName(), notes.getChangeId());
+    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.now())) {
       u.setNotify(notify);
-      u.addOp(notes.getChangeId(), op).execute();
+      u.addOp(notes.getChangeId(), op);
+      u.addOp(
+          notes.getChangeId(),
+          storeSubmitRequirementsOpFactory.create(
+              changeData.submitRequirements().values(), changeData));
+      u.execute();
     }
     return op.getChange();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index cb1256c..a21431e 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -92,7 +92,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.now())) {
       AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
       bu.addOp(changeResource.getId(), op);
       NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
diff --git a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
index ebec3295..e3ab135 100644
--- a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
+++ b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Set;
 
 @Singleton
@@ -33,14 +31,14 @@
 
   @Inject
   AllowedFormats(DownloadConfig cfg) {
-    Map<String, ArchiveFormatInternal> exts = new HashMap<>();
+    ImmutableMap.Builder<String, ArchiveFormatInternal> exts = ImmutableMap.builder();
     for (ArchiveFormatInternal format : cfg.getArchiveFormats()) {
       for (String ext : format.getSuffixes()) {
         exts.put(ext, format);
       }
       exts.put(format.name().toLowerCase(), format);
     }
-    extensions = ImmutableMap.copyOf(exts);
+    extensions = exts.build();
 
     // Zip is not supported because it may be interpreted by a Java plugin as a
     // valid JAR file, whose code would have access to cookies on the domain.
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index 9224154..a1c51e8 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -43,7 +44,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class ApplyFix implements RestModifyView<FixResource, Void> {
+public class ApplyFix implements RestModifyView<FixResource, Input> {
 
   private final GitRepositoryManager gitRepositoryManager;
   private final FixReplacementInterpreter fixReplacementInterpreter;
@@ -66,7 +67,7 @@
   }
 
   @Override
-  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
+  public Response<EditInfo> apply(FixResource fixResource, Input nothing)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
           ResourceNotFoundException, PermissionBackendException {
     RevisionResource revisionResource = fixResource.getRevisionResource();
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 318b0fa..19cfd6a 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -127,10 +127,11 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource resource, IdString id, FileContentInput input)
+    public Response<Object> apply(
+        ChangeResource resource, IdString id, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      putEdit.apply(resource, id.get(), input);
+      putEdit.apply(resource, id.get(), fileContentInput);
       return Response.none();
     }
   }
@@ -146,7 +147,7 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource rsrc, IdString id, Input in)
+    public Response<Object> apply(ChangeResource rsrc, IdString id, Input input)
         throws IOException, AuthException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
       return deleteContent.apply(rsrc, id.get());
@@ -249,15 +250,16 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource resource, Post.Input input)
+    public Response<Object> apply(ChangeResource resource, Post.Input postInput)
         throws AuthException, BadRequestException, IOException, ResourceConflictException,
             PermissionBackendException {
       Project.NameKey project = resource.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        if (isRestoreFile(input)) {
-          editModifier.restoreFile(repository, resource.getNotes(), input.restorePath);
-        } else if (isRenameFile(input)) {
-          editModifier.renameFile(repository, resource.getNotes(), input.oldPath, input.newPath);
+        if (isRestoreFile(postInput)) {
+          editModifier.restoreFile(repository, resource.getNotes(), postInput.restorePath);
+        } else if (isRenameFile(postInput)) {
+          editModifier.renameFile(
+              repository, resource.getNotes(), postInput.oldPath, postInput.newPath);
         } else {
           editModifier.createEdit(repository, resource.getNotes());
         }
@@ -267,14 +269,14 @@
       return Response.none();
     }
 
-    private static boolean isRestoreFile(Input input) {
-      return input != null && !Strings.isNullOrEmpty(input.restorePath);
+    private static boolean isRestoreFile(Post.Input postInput) {
+      return postInput != null && !Strings.isNullOrEmpty(postInput.restorePath);
     }
 
-    private static boolean isRenameFile(Input input) {
-      return input != null
-          && !Strings.isNullOrEmpty(input.oldPath)
-          && !Strings.isNullOrEmpty(input.newPath);
+    private static boolean isRenameFile(Post.Input postInput) {
+      return postInput != null
+          && !Strings.isNullOrEmpty(postInput.oldPath)
+          && !Strings.isNullOrEmpty(postInput.newPath);
     }
   }
 
@@ -300,37 +302,38 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput input)
+    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      return apply(rsrc.getChangeResource(), rsrc.getPath(), input);
+      return apply(rsrc.getChangeResource(), rsrc.getPath(), fileContentInput);
     }
 
-    public Response<Object> apply(ChangeResource rsrc, String path, FileContentInput input)
+    public Response<Object> apply(
+        ChangeResource rsrc, String path, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
 
-      if (input.content == null && input.binary_content == null) {
+      if (fileContentInput.content == null && fileContentInput.binary_content == null) {
         throw new BadRequestException("either content or binary_content is required");
       }
 
       RawInput newContent;
-      if (input.binary_content != null) {
-        Matcher m = BINARY_DATA_PATTERN.matcher(input.binary_content);
+      if (fileContentInput.binary_content != null) {
+        Matcher m = BINARY_DATA_PATTERN.matcher(fileContentInput.binary_content);
         if (m.matches() && BASE64.equals(m.group(2))) {
           newContent = RawInputUtil.create(Base64.decode(m.group(3)));
         } else {
           throw new BadRequestException("binary_content must be encoded as base64 data uri");
         }
       } else {
-        newContent = input.content;
+        newContent = fileContentInput.content;
       }
 
-      if (Patch.COMMIT_MSG.equals(path) && input.binary_content == null) {
-        EditMessage.Input editCommitMessageInput = new EditMessage.Input();
-        editCommitMessageInput.message =
+      if (Patch.COMMIT_MSG.equals(path) && fileContentInput.binary_content == null) {
+        EditMessage.Input editMessageInput = new EditMessage.Input();
+        editMessageInput.message =
             new String(ByteStreams.toByteArray(newContent.getInputStream()), UTF_8);
-        return editMessage.apply(rsrc, editCommitMessageInput);
+        return editMessage.apply(rsrc, editMessageInput);
       }
 
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
@@ -471,16 +474,16 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource rsrc, Input input)
+    public Response<Object> apply(ChangeResource rsrc, EditMessage.Input editMessageInput)
         throws AuthException, IOException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
-      if (input == null || Strings.isNullOrEmpty(input.message)) {
+      if (editMessageInput == null || Strings.isNullOrEmpty(editMessageInput.message)) {
         throw new BadRequestException("commit message must be provided");
       }
 
       Project.NameKey project = rsrc.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
+        editModifier.modifyMessage(repository, rsrc.getNotes(), editMessageInput.message);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 02b4c13..e09f2f4 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -143,7 +143,6 @@
     get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
-    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
     post(REVISION_KIND, "submit").to(Submit.class);
     post(REVISION_KIND, "rebase").to(Rebase.class);
     put(REVISION_KIND, "description").to(PutDescription.class);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index 572f704..a0c5b16 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -123,9 +122,7 @@
   }
 
   private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
+    if (!permissionBackend.currentUser().change(notes).test(ChangePermission.READ)) {
       return false;
     }
     Optional<ProjectState> projectState = projectCache.get(notes.getProjectName());
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index c8771ee..6a25095 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -67,9 +68,10 @@
 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.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -170,7 +172,7 @@
         patch.commitId(),
         input,
         dest,
-        TimeUtil.nowTs(),
+        TimeUtil.now(),
         null,
         null,
         null,
@@ -205,7 +207,7 @@
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null, null);
+        sourceChange, project, sourceCommit, input, dest, TimeUtil.now(), null, null, null, null);
   }
 
   /**
@@ -243,14 +245,13 @@
       ObjectId sourceCommit,
       CherryPickInput input,
       BranchNameKey dest,
-      Timestamp timestamp,
+      Instant timestamp,
       @Nullable Change.Id revertedChange,
       @Nullable ObjectId changeIdForNewChange,
       @Nullable Change.Id idForNewChange,
       @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
-
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -357,6 +358,7 @@
                   cherryPickCommit,
                   sourceChange,
                   newTopic,
+                  input,
                   workInProgress);
         } else {
           // Change key not found on destination branch. We can create a new
@@ -439,6 +441,7 @@
       CodeReviewCommit cherryPickCommit,
       @Nullable Change sourceChange,
       String topic,
+      CherryPickInput input,
       @Nullable Boolean workInProgress)
       throws IOException {
     Change destChange = destNotes.getChange();
@@ -452,6 +455,7 @@
     if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
       inserter.setWorkInProgress(false);
     }
+    inserter.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -502,6 +506,7 @@
           (sourceChange != null && sourceChange.isWorkInProgress())
               || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     }
+    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -547,6 +552,20 @@
     return changeId;
   }
 
+  private 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 NotifyResolver.Result resolveNotify(CherryPickInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     return notifyResolver.resolve(
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 1a4eb18..24a9c83 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.patch.DiffMappings;
 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.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
@@ -193,10 +194,9 @@
                 patchsetComments));
       } else {
         logger.atWarning().log(
-            String.format(
-                "Some comments which should be ported refer to the non-existent patchset %s of"
-                    + " change %d. Omitting %d affected comments.",
-                originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()));
+            "Some comments which should be ported refer to the non-existent patchset %s of"
+                + " change %d. Omitting %d affected comments.",
+            originalPatchsetId, notes.getChangeId().get(), patchsetComments.size());
       }
     }
     return portedComments.build();
@@ -254,8 +254,8 @@
         mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
       } catch (Exception e) {
         logger.atWarning().withCause(e).log(
-            "Could not determine some necessary diff mappings for porting comments on change %s from"
-                + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+            "Could not determine some necessary diff mappings for porting comments on change %s"
+                + " from patchset %s to patchset %s. Mapping %d affected comments to the fallback"
                 + " destination.",
             change.getChangeId(),
             originalPatchset.id().getId(),
@@ -315,7 +315,11 @@
         TraceContext.newTimer(
             "Computing diffs", Metadata.builder().commit(originalCommit.name()).build())) {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffOperations.listModifiedFiles(project, originalCommit, targetCommit);
+          diffOperations.listModifiedFiles(
+              project,
+              originalCommit,
+              targetCommit,
+              DiffOptions.builder().skipFilesWithAllEditsDueToRebase(false).build());
       return modifiedFiles.values().stream()
           .map(CommentPorter::getFileEdits)
           .map(DiffMappings::toMapping)
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index fa47bef..6a637b3 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -84,8 +84,9 @@
 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.Collections;
+import java.util.Date;
 import java.util.List;
 import java.util.Optional;
 import java.util.TimeZone;
@@ -319,6 +320,9 @@
     }
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private ChangeInfo createNewChange(
       ChangeInput input,
       IdentifiedUser me,
@@ -353,13 +357,14 @@
 
       RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
 
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
       PersonIdent committer = me.newCommitterIdent(now, serverTimeZone);
       PersonIdent author =
           input.author == null
               ? committer
-              : new PersonIdent(input.author.name, input.author.email, now, serverTimeZone);
+              : new PersonIdent(
+                  input.author.name, input.author.email, Date.from(now), serverTimeZone);
 
       String commitMessage = getCommitMessage(input.subject, me);
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 8476767..9e9cf6a 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -82,8 +82,7 @@
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getPatchSet().id(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index e943e47..651bf7b 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -67,7 +67,8 @@
 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.Date;
 import java.util.List;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
@@ -122,6 +123,9 @@
     this.permissionBackend = permissionBackend;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, MergePatchSetInput in)
       throws IOException, RestApiException, UpdateException, PermissionBackendException {
@@ -176,12 +180,12 @@
         currentPsCommit = rw.parseCommit(ps.commitId());
       }
 
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author =
           in.author == null
               ? me.newCommitterIdent(now, serverTimeZone)
-              : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
+              : new PersonIdent(in.author.name, in.author.email, Date.from(now), serverTimeZone);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index d867e00..d818210 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -67,8 +67,7 @@
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index 3ca5463..8298abb 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -54,8 +54,7 @@
     }
     rsrc.permissions().check(ChangePermission.DELETE);
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, opFactory.create(id));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 0e868e70..588d56e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -89,7 +89,7 @@
     DeleteChangeMessageOp deleteChangeMessageOp =
         new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
     try (BatchUpdate batchUpdate =
-        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.nowTs())) {
+        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.now())) {
       batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 044fd77..2056664 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -84,7 +84,7 @@
     String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
     DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
     try (BatchUpdate batchUpdate =
-        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.now())) {
       batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 51a0b8e..7d28a39 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -54,7 +54,7 @@
   public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getComment().key);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 16b7136..08725b5 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -62,8 +62,7 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(false, input);
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index db8e9de..7a409e8 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -58,7 +58,7 @@
         updateFactory.create(
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
-            TimeUtil.nowTs())) {
+            TimeUtil.now())) {
       bu.setNotify(getNotify(rsrc.getChange(), input));
       BatchUpdateOp op;
       if (rsrc.isByEmail()) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 7ee38d4..208cecf 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
@@ -81,7 +82,7 @@
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
-  private final AddToAttentionSetOp.Factory attentionSetOpfactory;
+  private final AddToAttentionSetOp.Factory attentionSetOpFactory;
   private final Provider<CurrentUser> currentUserProvider;
 
   @Inject
@@ -108,7 +109,7 @@
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
-    this.attentionSetOpfactory = attentionSetOpFactory;
+    this.attentionSetOpFactory = attentionSetOpFactory;
     this.currentUserProvider = currentUserProvider;
   }
 
@@ -133,7 +134,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
+            change.getProject(), r.getChangeResource().getUser(), TimeUtil.now())) {
       bu.setNotify(
           notifyResolver.resolve(
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
@@ -146,14 +147,18 @@
               r.getReviewerUser().state(),
               rsrc.getLabel(),
               input));
-      if (!r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+      if (!input.ignoreAutomaticAttentionSetRules
+          && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
             change.getId(),
-            attentionSetOpfactory.create(
+            attentionSetOpFactory.create(
                 r.getReviewerUser().getAccountId(),
                 /* reason= */ "Their vote was deleted",
                 /* notify= */ false));
       }
+      if (input.ignoreAutomaticAttentionSetRules) {
+        bu.addOp(change.getId(), new AttentionSetUnchangedOp());
+      }
       bu.execute();
     }
 
@@ -192,9 +197,7 @@
 
       Account.Id accountId = accountState.account().id();
 
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
         if (!labelTypes.byLabel(a.labelId()).isPresent()) {
           continue; // Ignore undefined labels.
         } else if (!a.label().equals(label)) {
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index ab5b9f4..a4c9400 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -75,7 +75,7 @@
   }
 
   private void checkIdentifiedUser() throws AuthException {
-    if (!(user.get().isIdentifiedUser())) {
+    if (!user.get().isIdentifiedUser()) {
       throw new AuthException("drafts only available to authenticated users");
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 320e57d..e996169 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 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.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -237,7 +238,7 @@
 
     private Collection<String> reviewed(RevisionResource resource) throws AuthException {
       CurrentUser user = self.get();
-      if (!(user.isIdentifiedUser())) {
+      if (!user.isIdentifiedUser()) {
         throw new AuthException("Authentication required");
       }
 
@@ -280,11 +281,14 @@
 
         Map<String, FileDiffOutput> oldList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, patchSet.commitId(), /* parentNum= */ 0);
+                project, patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
         Map<String, FileDiffOutput> curList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
+                project,
+                resource.getPatchSet().commitId(),
+                /* parentNum= */ 0,
+                DiffOptions.DEFAULTS);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index d76ce04..5193501 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.concurrent.TimeUnit.DAYS;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -25,7 +27,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -64,10 +65,11 @@
                   commit,
                   addLinks,
                   /* fillCommit= */ true,
-                  rsrc.getChange().getDest().branch());
+                  rsrc.getChange().getDest().branch(),
+                  rsrc.getChange().getKey().get());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
-        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+        r.caching(CacheControl.PRIVATE(7, DAYS));
       }
       return r;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index f0639b5..551b50f 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.concurrent.TimeUnit.DAYS;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
@@ -30,7 +32,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -77,7 +78,7 @@
         return createResponse(rsrc, ImmutableList.of());
       }
 
-      List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
+      ImmutableList<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
       List<CommitInfo> result = new ArrayList<>(commits.size());
       RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
@@ -88,7 +89,8 @@
                 c,
                 addLinks,
                 /* fillCommit= */ true,
-                rsrc.getChange().getDest().branch()));
+                rsrc.getChange().getDest().branch(),
+                rsrc.getChange().getKey().get()));
       }
       return createResponse(rsrc, result);
     }
@@ -98,7 +100,7 @@
       RevisionResource rsrc, List<CommitInfo> result) {
     Response<List<CommitInfo>> r = Response.ok(result);
     if (rsrc.isCacheable()) {
-      r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      r.caching(CacheControl.PRIVATE(7, DAYS));
     }
     return r;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index 187ebce..dea4dc4 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -43,8 +43,6 @@
 public class GetPatch implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
 
-  private static final String FILE_NOT_FOUND = "File not found: %s.";
-
   @Option(name = "--zip")
   private boolean zip;
 
@@ -118,7 +116,7 @@
             };
 
         if (path != null && bin.asString().isEmpty()) {
-          throw new ResourceNotFoundException(String.format(FILE_NOT_FOUND, path));
+          throw new ResourceNotFoundException(String.format("File not found: %s.", path));
         }
 
         if (zip) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 0eef468..7a1808b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -37,7 +38,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -63,7 +63,7 @@
     return Response.ok(relatedChangesInfo);
   }
 
-  public List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
+  public ImmutableList<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
       throws IOException, PermissionBackendException {
     boolean isEdit = rsrc.getEdit().isPresent();
     PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
@@ -92,10 +92,10 @@
     if (result.size() == 1) {
       RelatedChangeAndCommitInfo r = result.get(0);
       if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().commitId().name())) {
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
     }
-    return result;
+    return ImmutableList.copyOf(result);
   }
 
   static RelatedChangeAndCommitInfo newChangeAndCommit(
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index f52e81e..900b9e5 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -159,7 +159,7 @@
     projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
 
     Op op = new Op(input);
-    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) {
       u.addOp(change.getId(), op);
       u.execute();
     }
@@ -257,11 +257,8 @@
      * proposal: https://gerrit-review.googlesource.com/c/gerrit/+/129171
      */
     private void updateApprovals(
-        ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project)
-        throws IOException {
-      for (PatchSetApproval psa :
-          approvalsUtil.byPatchSet(
-              ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+        ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project) {
+      for (PatchSetApproval psa : approvalsUtil.byPatchSet(ctx.getNotes(), psId)) {
         ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
         Optional<LabelType> type =
             projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index c1a6a13..bcaa145 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -48,7 +48,7 @@
     req.permissions().check(ChangePermission.EDIT_HASHTAGS);
 
     try (BatchUpdate bu =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
       bu.addOp(req.getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index f774457..45d7250 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -74,8 +74,7 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(true, input);
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 5c252f4..4605d7c 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -137,6 +137,7 @@
 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;
@@ -274,10 +275,10 @@
   public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
-    return apply(revision, input, TimeUtil.nowTs());
+    return apply(revision, input, TimeUtil.now());
   }
 
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Instant ts)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
     // Respect timestamp, but truncate at change created-on time.
@@ -530,7 +531,7 @@
       ChangeData cd,
       PatchSet patchSet,
       List<ReviewerModification> reviewerModifications,
-      Timestamp when) {
+      Instant when) {
     List<AccountState> newlyAddedReviewers = new ArrayList<>();
 
     // There are no events for CCs and reviewers added/deleted by email.
@@ -1203,7 +1204,7 @@
                     parent);
           } else {
             // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
-            comment.writtenOn = ctx.getWhen();
+            comment.writtenOn = Timestamp.from(ctx.getWhen());
             comment.side = inputComment.side();
             comment.message = inputComment.message;
           }
@@ -1230,7 +1231,9 @@
         throws CommentsRejectedException {
       CommentValidationContext commentValidationCtx =
           CommentValidationContext.create(
-              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
+              ctx.getChange().getChangeId(),
+              ctx.getChange().getProject().get(),
+              ctx.getChange().getDest().branch());
       String changeMessage = Strings.nullToEmpty(in.message).trim();
       ImmutableList<CommentForValidation> draftsForValidation =
           Stream.concat(
@@ -1309,17 +1312,18 @@
       return robotComment;
     }
 
-    private List<FixSuggestion> createFixSuggestionsFromInput(
+    private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
         List<FixSuggestionInfo> fixSuggestionInfos) {
       if (fixSuggestionInfos == null) {
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
 
-      List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
+      ImmutableList.Builder<FixSuggestion> fixSuggestions =
+          ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
       for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
         fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
       }
-      return fixSuggestions;
+      return fixSuggestions.build();
     }
 
     private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
@@ -1406,7 +1410,7 @@
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws ResourceConflictException, IOException {
+        throws ResourceConflictException {
       Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
 
       // If no labels were modified and change is closed, abort early.
@@ -1573,18 +1577,12 @@
     }
 
     private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
-        throws IOException {
+        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
       LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
       Map<String, PatchSetApproval> current = new HashMap<>();
 
       for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getNotes(),
-              psId,
-              user.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
+          approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
         if (a.isLegacySubmit()) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 4691550..9bc80a4 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -70,8 +70,7 @@
     if (modification.op == null) {
       return Response.ok(modification.result);
     }
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(resolveNotify(rsrc, input));
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, modification.op);
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
deleted file mode 100644
index 4acf809..0000000
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ArchiveFormatInternal;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream.LimitExceededException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.submit.MergeOp;
-import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Collection;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.storage.pack.PackConfig;
-import org.eclipse.jgit.transport.BundleWriter;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-
-public class PreviewSubmit implements RestReadView<RevisionResource> {
-  private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
-
-  private final Provider<MergeOp> mergeOpProvider;
-  private final AllowedFormats allowedFormats;
-  private int maxBundleSize;
-  private String format;
-
-  @Option(name = "--format")
-  public void setFormat(String f) {
-    this.format = f;
-  }
-
-  @Inject
-  PreviewSubmit(
-      Provider<MergeOp> mergeOpProvider,
-      AllowedFormats allowedFormats,
-      @GerritServerConfig Config cfg) {
-    this.mergeOpProvider = mergeOpProvider;
-    this.allowedFormats = allowedFormats;
-    this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
-  }
-
-  @Override
-  public Response<BinaryResult> apply(RevisionResource rsrc)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (Strings.isNullOrEmpty(format)) {
-      throw new BadRequestException("format is not specified");
-    }
-    ArchiveFormatInternal f = allowedFormats.extensions.get("." + format);
-    if (f == null && format.equals("tgz")) {
-      // Always allow tgz, even when the allowedFormats doesn't contain it.
-      // Then we allow at least one format even if the list of allowed
-      // formats is empty.
-      f = ArchiveFormatInternal.TGZ;
-    }
-    if (f == null) {
-      throw new BadRequestException("unknown archive format");
-    }
-
-    Change change = rsrc.getChange();
-    if (!change.isNew()) {
-      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
-    }
-    if (!rsrc.getUser().isIdentifiedUser()) {
-      throw new MethodNotAllowedException("Anonymous users cannot submit");
-    }
-
-    return Response.ok(getBundles(rsrc, f));
-  }
-
-  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormatInternal f)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
-    Change change = rsrc.getChange();
-
-    @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
-    MergeOp op = mergeOpProvider.get();
-    try {
-      op.merge(change, caller, false, new SubmitInput(), true);
-      BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
-      bin.disableGzip()
-          .setContentType(f.getMimeType())
-          .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
-      return bin;
-    } catch (RestApiException
-        | UpdateException
-        | IOException
-        | ConfigInvalidException
-        | RuntimeException
-        | PermissionBackendException e) {
-      op.close();
-      throw e;
-    }
-  }
-
-  private static class SubmitPreviewResult extends BinaryResult {
-
-    private final MergeOp mergeOp;
-    private final ArchiveFormatInternal archiveFormat;
-    private final int maxBundleSize;
-
-    private SubmitPreviewResult(
-        MergeOp mergeOp, ArchiveFormatInternal archiveFormat, int maxBundleSize) {
-      this.mergeOp = mergeOp;
-      this.archiveFormat = archiveFormat;
-      this.maxBundleSize = maxBundleSize;
-    }
-
-    @Override
-    public void writeTo(OutputStream out) throws IOException {
-      try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
-        MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
-        for (Project.NameKey p : mergeOp.getAllProjects()) {
-          OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
-          bw.setObjectCountCallback(null);
-          bw.setPackConfig(new PackConfig(or.getRepo()));
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
-          for (ReceiveCommand r : refs) {
-            bw.include(r.getRefName(), r.getNewId());
-            ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())
-                // Probably the client doesn't already have NoteDb data.
-                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
-              bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
-            }
-          }
-          LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
-          bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
-          // This naming scheme cannot produce directory/file conflicts
-          // as no projects contains ".git/":
-          String path = p.get() + ".git";
-          archiveFormat.putEntry(aos, path, bos.toByteArray());
-        }
-      } catch (LimitExceededException e) {
-        throw new NotImplementedException("The bundle is too big to generate at the server", e);
-      } catch (NoSuchProjectException e) {
-        throw new IOException(e);
-      }
-    }
-
-    @Override
-    public void close() throws IOException {
-      mergeOp.close();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index dcf616c..d41620e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -98,7 +98,7 @@
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 7c54074..5b5bc15 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -57,7 +57,7 @@
 
     Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
     try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getChange().getId(), op);
       u.execute();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 84a3d89..6411087 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -40,7 +40,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.Optional;
 
@@ -87,7 +87,7 @@
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -145,7 +145,7 @@
     }
   }
 
-  private static HumanComment update(HumanComment e, DraftInput in, Timestamp when) {
+  private static HumanComment update(HumanComment e, DraftInput in, Instant when) {
     if (in.side != null) {
       e.side = in.side();
     }
@@ -154,7 +154,7 @@
     }
     e.setLineNbrAndRange(in.line, in.range);
     e.message = in.message.trim();
-    e.writtenOn = when;
+    e.setWrittenOn(when);
     if (in.tag != null) {
       // TODO(dborowitz): Can we support changing tags via PUT?
       e.tag = in.tag;
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 1ed7fd7..c62200a 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -47,7 +47,7 @@
 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.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -126,7 +126,7 @@
         throw new ResourceConflictException("new and existing commit message are the same");
       }
 
-      Timestamp ts = TimeUtil.nowTs();
+      Instant ts = TimeUtil.now();
       try (BatchUpdate bu =
           updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
         // Ensure that BatchUpdate will update the same repo
@@ -161,7 +161,7 @@
       ObjectInserter objectInserter,
       RevCommit basePatchSetCommit,
       String commitMessage,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException {
     CommitBuilder builder = new CommitBuilder();
     builder.setTreeId(basePatchSetCommit.getTree());
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 3031781..c9b436e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -63,7 +63,7 @@
 
     SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
     try (BatchUpdate u =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 2077fb8..5e30dae 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -53,6 +55,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -114,7 +117,7 @@
         ObjectReader reader = oi.newReader();
         RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
-            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
       if (!change.isNew()) {
         throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
@@ -126,6 +129,7 @@
               .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
               .setAllowConflicts(input.allowConflicts)
+              .setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions))
               .setFireRevisionCreated(true);
       // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
       bu.setNotify(NotifyResolver.Result.none());
@@ -215,7 +219,9 @@
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Rebase")
-            .setTitle("Rebase onto tip of branch or parent change")
+            .setTitle(
+                "Rebase onto tip of branch or parent change. Makes you the uploader of this "
+                    + "change which can affect validity of approvals.")
             .setVisible(false);
 
     Change change = rsrc.getChange();
@@ -246,6 +252,20 @@
     return description;
   }
 
+  private 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();
+  }
+
   public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index 7fe463e..bd3e8ec 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -81,7 +81,7 @@
     ChangeResource changeResource = attentionResource.getChangeResource();
     try (BatchUpdate bu =
         updateFactory.create(
-            changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+            changeResource.getProject(), changeResource.getUser(), TimeUtil.now())) {
       RemoveFromAttentionSetOp op =
           opFactory.create(attentionResource.getAccountId(), input.reason, true);
       bu.addOp(changeResource.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 53d0f18..49286fc 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -151,7 +151,7 @@
             commentsUtil.newHumanComment(
                 changeNotes,
                 currentUser,
-                TimeUtil.nowTs(),
+                TimeUtil.now(),
                 commentInput.path,
                 commentInput.patchSet == null
                     ? changeNotes.getChange().currentPatchSetId()
@@ -327,7 +327,7 @@
       // message here, then it would be possible to probe whether an account exists.
     } catch (AuthException ex) {
       // adding users without permission to the attention set should fail silently.
-      logger.atFine().log(ex.getMessage());
+      logger.atFine().log("%s", ex.getMessage());
     }
   }
 
@@ -352,7 +352,7 @@
       // message here, then it would be possible to probe whether an account exists.
     } catch (AuthException ex) {
       // this should never happen since removing users with permissions should work.
-      logger.atSevere().log(ex.getMessage());
+      logger.atSevere().log("%s", ex.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index b2d1d3a..19d0677 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -100,7 +100,7 @@
 
     Op op = new Op(input);
     try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
     return Response.ok(json.noOptions().format(op.change));
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 8d48c88..7dd3e7a 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -47,7 +47,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -99,12 +98,11 @@
     if (patch == null) {
       throw new ResourceNotFoundException(changeIdToRevert.toString());
     }
-    Timestamp timestamp = TimeUtil.nowTs();
     return Response.ok(
         json.noOptions()
             .format(
                 rsrc.getProject(),
-                commitUtil.createRevertChange(notes, rsrc.getUser(), input, timestamp)));
+                commitUtil.createRevertChange(notes, rsrc.getUser(), input, TimeUtil.now())));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 1b11eb0..4e5027b 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -80,8 +80,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -92,7 +92,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -251,7 +251,7 @@
     Multimap<BranchNameKey, ChangeData> changesPerProjectAndBranch = ArrayListMultimap.create();
     changeData.stream().forEach(c -> changesPerProjectAndBranch.put(c.change().getDest(), c));
     cherryPickInput = createCherryPickInput(revertInput);
-    Timestamp timestamp = TimeUtil.nowTs();
+    Instant timestamp = TimeUtil.now();
 
     for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) {
       cherryPickInput.base = null;
@@ -290,7 +290,7 @@
       Project.NameKey project,
       Iterator<PatchSetData> sortedChangesInProjectAndBranch,
       Set<ObjectId> commitIdsInProjectAndBranch,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
 
@@ -314,10 +314,7 @@
   }
 
   private void createCherryPickedRevert(
-      RevertInput revertInput,
-      Project.NameKey project,
-      ChangeNotes changeNotes,
-      Timestamp timestamp)
+      RevertInput revertInput, Project.NameKey project, ChangeNotes changeNotes, Instant timestamp)
       throws IOException, ConfigInvalidException, UpdateException, RestApiException {
     ObjectId revCommitId =
         commitUtil.createRevertCommit(revertInput.message, changeNotes, user.get(), timestamp);
@@ -326,7 +323,7 @@
     cherryPickInput.message = revertInput.message;
     ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
     Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
-    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.setNotify(
           notifyResolver.resolve(
               firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
@@ -349,7 +346,7 @@
   }
 
   private void createNormalRevert(
-      RevertInput revertInput, ChangeNotes changeNotes, Timestamp timestamp)
+      RevertInput revertInput, ChangeNotes changeNotes, Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException {
 
     Change.Id revertId =
@@ -558,14 +555,14 @@
     private final ObjectId revCommitId;
     private final ObjectId computedChangeId;
     private final Change.Id cherryPickRevertChangeId;
-    private final Timestamp timestamp;
+    private final Instant timestamp;
     private final boolean workInProgress;
 
     CreateCherryPickOp(
         ObjectId revCommitId,
         ObjectId computedChangeId,
         Change.Id cherryPickRevertChangeId,
-        Timestamp timestamp,
+        Instant timestamp,
         Boolean workInProgress) {
       this.revCommitId = revCommitId;
       this.computedChangeId = computedChangeId;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 7f7c1ad..d8d51d4 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -44,6 +43,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -56,7 +56,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
-import org.apache.commons.lang.mutable.MutableDouble;
+import org.apache.commons.lang3.mutable.MutableDouble;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
@@ -227,7 +227,7 @@
     } catch (QueryParseException e) {
       // Unhandled, because owner:self will never provoke a QueryParseException
       logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
-      return ImmutableMap.of();
+      return new HashMap<>();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index d6c4c51..e3cf4db 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -413,7 +413,7 @@
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
-    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
+    logger.atFine().log("maxAllowedWithoutConfirmation: %s", maxAllowedWithoutConfirmation);
 
     if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) {
       logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 69b82ba..41fecaf 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
@@ -38,9 +39,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -121,7 +120,7 @@
     }
   }
 
-  private List<RevisionResource> find(ChangeResource change, String id)
+  private ImmutableList<RevisionResource> find(ChangeResource change, String id)
       throws IOException, AuthException {
     if (id.equals("0") || id.equals("edit")) {
       return loadEdit(change, null);
@@ -131,7 +130,7 @@
     } else if (id.length() < 4 || id.length() > ObjectIds.STR_LEN) {
       // Require a minimum of 4 digits.
       // Impossibly long identifier will never match.
-      return Collections.emptyList();
+      return ImmutableList.of();
     } else {
       List<RevisionResource> out = new ArrayList<>();
       for (PatchSet ps : psUtil.byChange(change.getNotes())) {
@@ -143,20 +142,23 @@
       if (out.isEmpty() && ObjectId.isId(id)) {
         return loadEdit(change, ObjectId.fromString(id));
       }
-      return out;
+      return ImmutableList.copyOf(out);
     }
   }
 
-  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
+  private ImmutableList<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
     PatchSet ps = psUtil.get(change.getNotes(), PatchSet.id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
-      return Collections.singletonList(new RevisionResource(change, ps));
+      return ImmutableList.of(new RevisionResource(change, ps));
     }
-    return Collections.emptyList();
+    return ImmutableList.of();
   }
 
-  private List<RevisionResource> loadEdit(ChangeResource change, @Nullable ObjectId commitId)
-      throws AuthException, IOException {
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
+  private ImmutableList<RevisionResource> loadEdit(
+      ChangeResource change, @Nullable ObjectId commitId) throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
     if (edit.isPresent()) {
       RevCommit editCommit = edit.get().getEditCommit();
@@ -165,12 +167,12 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
-              .createdOn(new Timestamp(editCommit.getCommitterIdent().getWhen().getTime()))
+              .createdOn(editCommit.getCommitterIdent().getWhen().toInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
-        return Collections.singletonList(new RevisionResource(change, ps, edit));
+        return ImmutableList.of(new RevisionResource(change, ps, edit));
       }
     }
-    return Collections.emptyList();
+    return ImmutableList.of();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index c118766..9f019b6 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -63,8 +63,7 @@
       throw new ResourceConflictException("change is not work in progress");
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index fdaad9d..0ad5180 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -63,8 +63,7 @@
       throw new ResourceConflictException("change is already work in progress");
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 876c92c..9597dde 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -257,9 +257,19 @@
           return "Change " + c.getId() + " is marked work in progress";
         }
         try {
-          MergeOp.checkSubmitRule(c, false);
+          // The data in the change index may be stale (e.g. if submit requirements have been
+          // changed). For that one change for which the submit action is computed, use the
+          // freshly loaded ChangeData instance 'cd' instead of the potentially stale ChangeData
+          // instance 'c' that was loaded from the index. This makes a difference if the ChangeSet
+          // 'cs' only contains this one single change. If the ChangeSet contains further changes
+          // those may still be stale.
+          MergeOp.checkSubmitRequirements(cd.getId().equals(c.getId()) ? cd : c);
         } catch (ResourceConflictException e) {
-          return "Change " + c.getId() + " is not ready: " + e.getMessage();
+          return (c.getId() == cd.getId())
+              ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
+              : String.format(
+                  "Change %s must be submitted with change %s but %s is not ready: %s",
+                  cd.getId(), c.getId(), c.getId(), e.getMessage());
         }
       }
 
@@ -299,12 +309,15 @@
 
     ChangeData cd = resource.getChangeResource().getChangeData();
     try {
-      MergeOp.checkSubmitRule(cd, false);
+      MergeOp.checkSubmitRequirements(cd);
     } catch (ResourceConflictException e) {
       return null; // submit not visible
     }
 
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -314,14 +327,6 @@
 
     String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
 
-    // Recheck mergeability rather than using value stored in the index, which may be stale.
-    // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
-    // index in the first place.
-    // cd.setMergeable(null);
-    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
-    // now it is safe to read from the cache, as it yields the same result.
-    Boolean enabled = cd.isMergeable();
-
     if (submitProblems != null) {
       return new UiAction.Description()
           .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
@@ -330,6 +335,14 @@
           .setEnabled(false);
     }
 
+    // Recheck mergeability rather than using value stored in the index, which may be stale.
+    // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
+    // index in the first place.
+    // cd.setMergeable(null);
+    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
+    // now it is safe to read from the cache, as it yields the same result.
+    Boolean enabled = cd.isMergeable();
+
     if (treatWithTopic) {
       Map<String, String> params =
           ImmutableMap.of(
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 214a001..2ce82ab 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 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 java.util.Collections.reverseOrder;
-import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
@@ -41,7 +43,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
@@ -127,7 +128,10 @@
       int hidden;
 
       if (c.isNew()) {
-        ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
+        ChangeSet cs =
+            mergeSuperSet
+                .get()
+                .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
         cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
       } else if (c.isMerged()) {
@@ -153,11 +157,11 @@
     }
   }
 
-  private List<ChangeData> sort(List<ChangeData> cds, int hidden) throws IOException {
+  private ImmutableList<ChangeData> sort(List<ChangeData> cds, int hidden) throws IOException {
     if (cds.size() <= 1 && hidden == 0) {
       // Skip sorting for singleton lists, to avoid WalkSorter opening the
       // repo just to fill out the commit field in PatchSetData.
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     long numProjectsDistinct = cds.stream().map(ChangeData::project).distinct().count();
@@ -167,15 +171,15 @@
       // We either have only a single change per project which means that WalkSorter won't make a
       // difference compared to our index-backed sort, or we are looking at more than 5 projects
       // which would make WalkSorter too expensive for this call.
-      return cds.stream().sorted(COMPARATOR).collect(toList());
+      return cds.stream().sorted(COMPARATOR).collect(toImmutableList());
     }
 
     // Perform more expensive walk-sort.
-    List<ChangeData> sorted = new ArrayList<>(cds.size());
+    ImmutableList.Builder<ChangeData> sorted = ImmutableList.builderWithExpectedSize(cds.size());
     for (PatchSetData psd : sorter.get().sort(cds)) {
       sorted.add(psd.data());
     }
-    return sorted;
+    return sorted.build();
   }
 
   private static List<ChangeData> ensureRequiredDataIsLoaded(List<ChangeData> cds) {
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index 02c2ff0..97f866b 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
@@ -28,12 +29,14 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
 import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.inject.Inject;
 import java.util.LinkedHashMap;
+import java.util.Optional;
 import org.kohsuke.args4j.Option;
 
 public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
@@ -74,9 +77,11 @@
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
 
-    projectCache
-        .get(rsrc.getProject())
-        .orElseThrow(() -> new BadRequestException("project not found " + rsrc.getProject()));
+    Project.NameKey name = rsrc.getProject();
+    Optional<ProjectState> project = projectCache.get(name);
+    if (!project.isPresent()) {
+      throw new BadRequestException("project not found " + name);
+    }
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitRecord record =
         prologRule.evaluate(
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index ae11d71..09052a6 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -66,8 +66,10 @@
 import com.google.inject.Inject;
 import java.nio.file.Files;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -153,6 +155,7 @@
 
     info.user = getUserInfo();
     info.receive = getReceiveInfo();
+    info.submitRequirementDashboardColumns = getSubmitRequirementDashboardColumns();
     return Response.ok(info);
   }
 
@@ -373,6 +376,10 @@
     return info;
   }
 
+  private List<String> getSubmitRequirementDashboardColumns() {
+    return Arrays.asList(config.getStringList("dashboard", null, "submitRequirementColumns"));
+  }
+
   private static Boolean toBoolean(boolean v) {
     return v ? v : null;
   }
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index eac9653..dcc44ae 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -129,7 +129,7 @@
     public TaskInfo(Task<?> task) {
       this.id = HexFormat.fromInt(task.getTaskId());
       this.state = task.getState();
-      this.startTime = new Timestamp(task.getStartTime().getTime());
+      this.startTime = Timestamp.from(task.getStartTime());
       this.delay = task.getDelay(TimeUnit.MILLISECONDS);
       this.command = task.toString();
       this.queueName = task.getQueueName();
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index ee86010..f257f86 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -212,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(TimeUtil.nowTs(), serverTimeZone)));
+                self.get().newCommitterIdent(TimeUtil.now(), serverTimeZone)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index e3aa0f3..1b0fcd4 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -102,7 +102,7 @@
           auditEvents.add(
               GroupAuditEventInfo.createRemoveUserEvent(
                   accountLoader.get(auditEvent.removedBy().orElse(null)),
-                  auditEvent.removedOn(),
+                  auditEvent.removedOn().orElse(null),
                   member));
         }
       }
@@ -134,7 +134,7 @@
           auditEvents.add(
               GroupAuditEventInfo.createRemoveGroupEvent(
                   accountLoader.get(auditEvent.removedBy().orElse(null)),
-                  auditEvent.removedOn(),
+                  auditEvent.removedOn().orElse(null),
                   member));
         }
       }
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index e1459c3..6d3fa01 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -121,7 +121,7 @@
       }
     }
 
-    info.createdOn = internalGroup.getCreatedOn();
+    info.setCreatedOn(internalGroup.getCreatedOn());
 
     if (options.contains(MEMBERS)) {
       info.members = listMembers.get().getDirectMembers(internalGroup, groupControlSupplier.get());
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index 08cc974..ac81f54 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -70,7 +70,7 @@
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
-    } else if (!(user.isIdentifiedUser())) {
+    } else if (!user.isIdentifiedUser()) {
       throw new ResourceNotFoundException();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 854f091..b94e44d 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -57,8 +57,8 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.NavigableMap;
 import java.util.Set;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.function.Predicate;
 import java.util.regex.Pattern;
@@ -235,8 +235,9 @@
   }
 
   @Override
-  public Response<SortedMap<String, GroupInfo>> apply(TopLevelResource resource) throws Exception {
-    SortedMap<String, GroupInfo> output = new TreeMap<>();
+  public Response<NavigableMap<String, GroupInfo>> apply(TopLevelResource resource)
+      throws Exception {
+    NavigableMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
       info.name = null;
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 22870c1..1882fc5 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -118,9 +118,8 @@
   protected List<AccountInfo> getMembers(InternalGroup group) throws PermissionBackendException {
     if (recursive) {
       return getTransitiveMembers(group);
-    } else {
-      return getDirectMembers(group);
     }
+    return getDirectMembers(group);
   }
 
   private List<AccountInfo> toAccountInfos(Set<Account.Id> members)
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 92038b0..8a0cc39 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
@@ -49,7 +50,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -113,8 +113,8 @@
         .checkStatePermitsWrite();
 
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-    List<AccessSection> removals = setAccess.getAccessSections(input.remove);
-    List<AccessSection> additions = setAccess.getAccessSections(input.add);
+    ImmutableList<AccessSection> removals = setAccess.getAccessSections(input.remove);
+    ImmutableList<AccessSection> additions = setAccess.getAccessSections(input.add);
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
@@ -152,7 +152,7 @@
           ObjectReader objReader = objInserter.newReader();
           RevWalk rw = new RevWalk(objReader);
           BatchUpdate bu =
-              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
+              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
         bu.setRepository(md.getRepository(), rw, objInserter);
         ChangeInserter ins = newInserter(changeId, commit);
         bu.insertChange(ins);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 33549fe..018ed86 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -45,6 +47,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -151,7 +154,11 @@
       u.setNewObjectId(object.copy());
       u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
       u.setRefLogMessage("created via REST from " + input.revision, false);
-      refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
+      refCreationValidator.validateRefOperation(
+          rsrc.getName(),
+          identifiedUser.get(),
+          u,
+          getValidateOptionsAsMultimap(input.validationOptions));
       RefUpdate.Result result = u.update(rw);
       switch (result) {
         case FAST_FORWARD:
@@ -213,4 +220,18 @@
   private boolean isBranchAllowed(String branch) {
     return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
   }
+
+  private 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();
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index e3f293a..01686ff 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -43,6 +43,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -148,6 +149,11 @@
     List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
     LabelType.Builder labelType = LabelType.builder(LabelType.checkName(label), values);
 
+    if (input.description != null) {
+      String description = Strings.emptyToNull(input.description.trim());
+      labelType.setDescription(Optional.ofNullable(description));
+    }
+
     if (input.function != null && !input.function.trim().isEmpty()) {
       labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
     } else {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index f3b2bad..8203346 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
@@ -57,7 +58,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -194,7 +194,8 @@
     }
   }
 
-  private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
+  private ImmutableList<String> normalizeBranchNames(List<String> branches)
+      throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
       // Use host-level default for HEAD or fall back to 'master' if nothing else was specified in
       // the input.
@@ -203,7 +204,7 @@
           defaultBranch != null
               ? normalizeAndValidateBranch(defaultBranch)
               : Constants.R_HEADS + Constants.MASTER;
-      return Collections.singletonList(defaultBranch);
+      return ImmutableList.of(defaultBranch);
     }
     List<String> normalizedBranches = new ArrayList<>();
     for (String branch : branches) {
@@ -212,7 +213,7 @@
         normalizedBranches.add(branch);
       }
     }
-    return normalizedBranches;
+    return ImmutableList.copyOf(normalizedBranches);
   }
 
   private String normalizeAndValidateBranch(String branch) throws BadRequestException {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index b552ff5..6980006 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -136,7 +136,7 @@
                   resource
                       .getUser()
                       .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
+                      .newCommitterIdent(TimeUtil.now(), TimeZone.getDefault()));
         }
 
         Ref result = tag.call();
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 60405a6..5a84f69 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -21,6 +21,7 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
@@ -118,7 +119,11 @@
       u.setExpectedOldObjectId(repository.exactRef(ref).getObjectId());
       u.setNewObjectId(ObjectId.zeroId());
       u.setForceUpdate(true);
-      refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
+      refDeletionValidator.validateRefOperation(
+          projectState.getName(),
+          identifiedUser.get(),
+          u,
+          /* pushOptions */ ImmutableListMultimap.of());
       result = u.delete();
 
       switch (result) {
@@ -245,7 +250,11 @@
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
     u.setNewObjectId(ObjectId.zeroId());
-    refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
+    refDeletionValidator.validateRefOperation(
+        projectState.getName(),
+        identifiedUser.get(),
+        u,
+        /* pushOptions */ ImmutableListMultimap.of());
     return command;
   }
 
@@ -269,7 +278,7 @@
         msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
         break;
     }
-    logger.atSevere().log(msg);
+    logger.atSevere().log("%s", msg);
     errorMessages.append(msg);
     errorMessages.append("\n");
   }
diff --git a/java/com/google/gerrit/server/restapi/project/FilesCollection.java b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
index 888ecf2..ca5284f 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
@@ -46,8 +46,14 @@
   @Override
   public FileResource parse(BranchResource parent, IdString id)
       throws ResourceNotFoundException, IOException {
+    if (parent.getRevision().isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
     return FileResource.create(
-        repoManager, parent.getProjectState(), ObjectId.fromString(parent.getRevision()), id.get());
+        repoManager,
+        parent.getProjectState(),
+        ObjectId.fromString(parent.getRevision().get()),
+        id.get());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index f9c6fd9..e0131ee 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.args4j.TimestampHandler;
+import com.google.gerrit.server.args4j.InstantHandler;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.lib.ReflogEntry;
@@ -60,9 +60,9 @@
       metaVar = "TIMESTAMP",
       usage =
           "timestamp from which the reflog entries should be listed (UTC, format: "
-              + TimestampHandler.TIMESTAMP_FORMAT
+              + InstantHandler.TIMESTAMP_FORMAT
               + ")")
-  public GetReflog setFrom(Timestamp from) {
+  public GetReflog setFrom(Instant from) {
     this.from = from;
     return this;
   }
@@ -72,16 +72,16 @@
       metaVar = "TIMESTAMP",
       usage =
           "timestamp until which the reflog entries should be listed (UTC, format: "
-              + TimestampHandler.TIMESTAMP_FORMAT
+              + InstantHandler.TIMESTAMP_FORMAT
               + ")")
-  public GetReflog setTo(Timestamp to) {
+  public GetReflog setTo(Instant to) {
     this.to = to;
     return this;
   }
 
   private int limit;
-  private Timestamp from;
-  private Timestamp to;
+  private Instant from;
+  private Instant to;
 
   @Inject
   public GetReflog(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
@@ -89,6 +89,9 @@
     this.permissionBackend = permissionBackend;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Override
   public Response<List<ReflogEntryInfo>> apply(BranchResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
@@ -103,7 +106,7 @@
         r = repo.getReflogReader(rsrc.getRef());
       } catch (UnsupportedOperationException e) {
         String msg = "reflog not supported on repo " + rsrc.getNameKey().get();
-        logger.atSevere().log(msg);
+        logger.atSevere().log("%s", msg);
         throw new MethodNotAllowedException(msg, e);
       }
       if (r == null) {
@@ -115,8 +118,8 @@
       } else {
         entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
-          Timestamp timestamp = new Timestamp(e.getWho().getWhen().getTime());
-          if ((from == null || from.before(timestamp)) && (to == null || to.after(timestamp))) {
+          Instant timestamp = e.getWho().getWhen().toInstant();
+          if ((from == null || from.isBefore(timestamp)) && (to == null || to.isAfter(timestamp))) {
             entries.add(e);
           }
           if (limit > 0 && entries.size() >= limit) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
index 4406719..8cedd60 100644
--- a/java/com/google/gerrit/server/restapi/project/ListDashboards.java
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -100,7 +100,7 @@
     return tree.values();
   }
 
-  private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
+  private ImmutableList<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
     if (!state.statePermitsRead()) {
       return ImmutableList.of();
@@ -109,7 +109,7 @@
     PermissionBackend.ForProject perm = permissionBackend.currentUser().project(state.getNameKey());
     try (Repository git = gitManager.openRepository(state.getNameKey());
         RevWalk rw = new RevWalk(git)) {
-      List<DashboardInfo> all = new ArrayList<>();
+      ImmutableList.Builder<DashboardInfo> all = ImmutableList.builder();
       for (Ref ref : git.getRefDatabase().getRefsByPrefix(REFS_DASHBOARDS)) {
         try {
           perm.ref(ref.getName()).check(RefPermission.READ);
@@ -118,13 +118,13 @@
           // Do nothing.
         }
       }
-      return all;
+      return all.build();
     } catch (RepositoryNotFoundException e) {
       throw new ResourceNotFoundException(project, e);
     }
   }
 
-  private List<DashboardInfo> scanDashboards(
+  private ImmutableList<DashboardInfo> scanDashboards(
       Project definingProject,
       Repository git,
       RevWalk rw,
@@ -132,7 +132,7 @@
       String project,
       boolean setDefault)
       throws IOException {
-    List<DashboardInfo> list = new ArrayList<>();
+    ImmutableList.Builder<DashboardInfo> list = ImmutableList.builder();
     try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
       tw.addTree(rw.parseTree(ref.getObjectId()));
       tw.setRecursive(true);
@@ -155,6 +155,6 @@
         }
       }
     }
-    return list;
+    return list.build();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 4d8005b..cd68a2f 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -76,9 +76,9 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NavigableSet;
 import java.util.Optional;
 import java.util.SortedMap;
-import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Stream;
@@ -666,7 +666,7 @@
       } catch (IllegalArgumentException e) {
         throw new BadRequestException(e.getMessage());
       }
-      return searcher.search(ImmutableList.copyOf(projectCache.all()));
+      return searcher.search(projectCache.all().asList());
     } else {
       return projectCache.all().stream();
     }
@@ -680,7 +680,7 @@
 
   private void printProjectTree(
       final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
-    final SortedSet<ProjectNode> sortedNodes = new TreeSet<>();
+    final NavigableSet<ProjectNode> sortedNodes = new TreeSet<>();
 
     // Builds the inheritance tree using a list.
     //
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 123c78a..eccdcfc 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -39,7 +39,7 @@
 import com.google.gerrit.server.project.RefFilter;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -172,6 +172,9 @@
     throw new ResourceNotFoundException(id);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public static TagInfo createTagInfo(
       PermissionBackend.ForRef perm, Ref ref, RevWalk rw, ProjectState projectState, WebLinks links)
       throws IOException {
@@ -197,12 +200,12 @@
           tagger != null ? CommonConverters.toGitPerson(tagger) : null,
           canDelete,
           webLinks.isEmpty() ? null : webLinks,
-          tagger != null ? new Timestamp(tagger.getWhen().getTime()) : null);
+          tagger != null ? tagger.getWhen().toInstant() : null);
     }
 
-    Timestamp timestamp =
+    Instant timestamp =
         object instanceof RevCommit
-            ? new Timestamp(((RevCommit) object).getCommitterIdent().getWhen().getTime())
+            ? ((RevCommit) object).getCommitterIdent().getWhen().toInstant()
             : null;
 
     // Lightweight tag
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectNode.java b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
index 1e6200c..816c69d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectNode.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.util.TreeFormatter.TreeNode;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import java.util.TreeSet;
 
 /** Node of a Project in a tree formatted by {@link ListProjects}. */
@@ -32,7 +32,7 @@
   private final Project project;
   private final boolean isVisible;
 
-  private final SortedSet<ProjectNode> children = new TreeSet<>();
+  private final NavigableSet<ProjectNode> children = new TreeSet<>();
 
   @Inject
   protected ProjectNode(
@@ -72,7 +72,7 @@
   }
 
   @Override
-  public SortedSet<? extends ProjectNode> getChildren() {
+  public NavigableSet<? extends ProjectNode> getChildren() {
     return children;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 65cc5a2..205420c 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
@@ -41,8 +42,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -66,13 +65,14 @@
     this.pluginPermissionsUtil = pluginPermissionsUtil;
   }
 
-  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+  ImmutableList<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
       throws UnprocessableEntityException {
     if (sectionInfos == null) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    ImmutableList.Builder<AccessSection> sections =
+        ImmutableList.builderWithExpectedSize(sectionInfos.size());
     for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
       if (entry.getValue().permissions == null) {
         continue;
@@ -120,7 +120,7 @@
       }
       sections.add(accessSection.build());
     }
-    return sections;
+    return sections.build();
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 801fac7..79bb4ee 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -38,6 +38,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -147,6 +148,12 @@
       }
     }
 
+    if (input.description != null) {
+      String description = Strings.emptyToNull(input.description.trim());
+      labelTypeBuilder.setDescription(Optional.ofNullable(description));
+      dirty = true;
+    }
+
     if (input.function != null) {
       if (input.function.trim().isEmpty()) {
         throw new BadRequestException("function cannot be empty");
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 9f64863..8cd0a58 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -39,6 +39,8 @@
  */
 @Singleton
 public final class DefaultSubmitRule implements SubmitRule {
+  public static final String RULE_NAME = "gerrit~DefaultSubmitRule";
+
   public static class DefaultSubmitRuleModule extends FactoryModule {
     @Override
     public void configure() {
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index bc0bb1a..2bf4175 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -35,10 +35,10 @@
 import com.googlecode.prolog_cafe.lang.PredicateEncoder;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
+import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
@@ -73,7 +73,7 @@
     setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
     args = a;
     storedValues = new HashMap<>();
-    cleanup = new LinkedList<>();
+    cleanup = new ArrayList<>();
   }
 
   public Args getArgs() {
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 77b64e8..ddc3fca 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -321,7 +321,7 @@
 
   private SubmitRecord ruleError(String err, Exception e) {
     if (opts.logErrors()) {
-      logger.atSevere().withCause(e).log(err);
+      logger.atSevere().withCause(e).log("%s", err);
       return createRuleError(DEFAULT_MSG);
     }
     logger.atFine().log("rule error: %s", err);
@@ -400,7 +400,7 @@
 
   private SubmitTypeRecord typeError(String err, Exception e) {
     if (opts.logErrors()) {
-      logger.atSevere().withCause(e).log(err);
+      logger.atSevere().withCause(e).log("%s", err);
     }
     return SubmitTypeRecord.error(err);
   }
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index fd66a3a..dbaefb9 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 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.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
@@ -70,7 +71,7 @@
   }
 
   public static final StoredValue<RevCommit> COMMIT =
-      new StoredValue<RevCommit>() {
+      new StoredValue<>() {
         @Override
         public RevCommit createValue(Prolog engine) {
           Change change = getChange(engine);
@@ -86,7 +87,7 @@
       };
 
   public static final StoredValue<Map<String, FileDiffOutput>> DIFF_LIST =
-      new StoredValue<Map<String, FileDiffOutput>>() {
+      new StoredValue<>() {
         @Override
         public Map<String, FileDiffOutput> createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -98,7 +99,7 @@
           try {
             diffList =
                 diffOperations.listModifiedFilesAgainstParent(
-                    project, ps.commitId(), /* parentNum= */ 0);
+                    project, ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
           } catch (DiffNotAvailableException e) {
             throw new SystemException(
                 String.format(
@@ -113,7 +114,7 @@
   // It should be minimized or cached to reduce pause time
   // when evaluating Prolog submit rules.
   public static final StoredValue<GitRepositoryManager> REPO_MANAGER =
-      new StoredValue<GitRepositoryManager>() {
+      new StoredValue<>() {
         @Override
         public GitRepositoryManager createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -122,7 +123,7 @@
       };
 
   public static final StoredValue<PluginConfigFactory> PLUGIN_CONFIG_FACTORY =
-      new StoredValue<PluginConfigFactory>() {
+      new StoredValue<>() {
         @Override
         public PluginConfigFactory createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -131,7 +132,7 @@
       };
 
   public static final StoredValue<Repository> REPOSITORY =
-      new StoredValue<Repository>() {
+      new StoredValue<>() {
         @Override
         public Repository createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -150,7 +151,7 @@
       };
 
   public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
-      new StoredValue<PermissionBackend>() {
+      new StoredValue<>() {
         @Override
         protected PermissionBackend createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -159,7 +160,7 @@
       };
 
   public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
-      new StoredValue<AnonymousUser>() {
+      new StoredValue<>() {
         @Override
         protected AnonymousUser createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -168,7 +169,7 @@
       };
 
   public static final StoredValue<Map<Account.Id, IdentifiedUser>> USERS =
-      new StoredValue<Map<Account.Id, IdentifiedUser>>() {
+      new StoredValue<>() {
         @Override
         protected Map<Account.Id, IdentifiedUser> createValue(Prolog engine) {
           return new HashMap<>();
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index daa24d8..c040347 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -52,8 +52,8 @@
                 LabelValue.create((short) 2, "Looks good to me, approved"),
                 LabelValue.create((short) 1, "Looks good to me, but someone else must approve"),
                 LabelValue.create((short) 0, "No score"),
-                LabelValue.create((short) -1, "I would prefer this is not merged as is"),
-                LabelValue.create((short) -2, "This shall not be merged")))
+                LabelValue.create((short) -1, "I would prefer this is not submitted as is"),
+                LabelValue.create((short) -2, "This shall not be submitted")))
         .setCopyMinScore(true)
         .setCopyAllScoresOnTrivialRebase(true)
         .build();
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index 0df7907..ce445e1 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -26,6 +26,5 @@
         "//lib/commons:dbcp",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 39e3a59..e7d8337 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -104,8 +104,8 @@
           "  defaultValue = 0",
           "  copyMinScore = true",
           "  copyAllScoresOnTrivialRebase = true",
-          "  value = -2 This shall not be merged",
-          "  value = -1 I would prefer this is not merged as is",
+          "  value = -2 This shall not be submitted",
+          "  value = -1 I would prefer this is not submitted as is",
           "  value = 0 No score",
           "  value = +1 Looks good to me, but someone else must approve",
           "  value = +2 Looks good to me, approved");
diff --git a/java/com/google/gerrit/server/securestore/SecureStoreProvider.java b/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
index 4e43b2e..547b6dc 100644
--- a/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
+++ b/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.server.config.SitePaths;
@@ -27,8 +26,6 @@
 
 @Singleton
 public class SecureStoreProvider implements Provider<SecureStore> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final Path libdir;
   private final Injector injector;
   private final String className;
@@ -56,9 +53,7 @@
     try {
       return (Class<? extends SecureStore>) Class.forName(className);
     } catch (ClassNotFoundException e) {
-      String msg = String.format("Cannot load secure store class: %s", className);
-      logger.atSevere().withCause(e).log(msg);
-      throw new RuntimeException(msg, e);
+      throw new RuntimeException(String.format("Cannot load secure store class: %s", className), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index a09ba63..b218347 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,9 +44,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
@@ -62,7 +62,7 @@
       }
       first = false;
     }
-    return ops;
+    return ops.build();
   }
 
   private class CherryPickRootOp extends SubmitStrategyOp {
@@ -102,8 +102,7 @@
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
 
-      PersonIdent committer =
-          args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+      PersonIdent committer = ctx.newCommitterIdent(args.caller);
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
@@ -196,7 +195,7 @@
           && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
-        PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
+        PersonIdent myIdent = ctx.newPersonIdent(args.serverIdent);
         CodeReviewCommit result =
             args.mergeUtil.mergeOneCommit(
                 myIdent,
diff --git a/java/com/google/gerrit/server/submit/FastForwardOnly.java b/java/com/google/gerrit/server/submit/FastForwardOnly.java
index 8a30898..ee8fec8 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOnly.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOnly.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -30,7 +29,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
 
     Map<BranchNameKey, CodeReviewCommit> branchToCommit = new HashMap<>();
@@ -47,7 +46,8 @@
       }
     }
 
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
     if (!newTipCommit.equals(args.mergeTip.getInitialTip())) {
@@ -57,7 +57,7 @@
         ops.add(new NotFastForwardOp(c));
       }
     }
-    return ops;
+    return ops.build();
   }
 
   private class NotFastForwardOp extends SubmitStrategyOp {
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 85efcd2..86d6c674 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -280,7 +280,7 @@
   }
 
   private void logErrorAndThrow(String msg) {
-    logger.atSevere().log(msg);
+    logger.atSevere().log("%s", msg);
     throw new StorageException(msg);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeAlways.java b/java/com/google/gerrit/server/submit/MergeAlways.java
index c3f186a..1118a29 100644
--- a/java/com/google/gerrit/server/submit/MergeAlways.java
+++ b/java/com/google/gerrit/server/submit/MergeAlways.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -25,9 +25,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
       // The branch is unborn. Take a fast-forward resolution to
       // create the branch.
@@ -38,7 +39,7 @@
       CodeReviewCommit n = sorted.remove(0);
       ops.add(new MergeOneOp(args, n));
     }
-    return ops;
+    return ops.build();
   }
 
   static boolean dryRun(
diff --git a/java/com/google/gerrit/server/submit/MergeIfNecessary.java b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
index 30f1661..75136f5 100644
--- a/java/com/google/gerrit/server/submit/MergeIfNecessary.java
+++ b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -25,9 +25,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
 
     if (args.mergeTip.getInitialTip() == null
         || !args.subscriptionGraph.hasSubscription(args.destBranch)) {
@@ -43,7 +44,7 @@
       CodeReviewCommit n = sorted.remove(0);
       ops.add(new MergeOneOp(args, n));
     }
-    return ops;
+    return ops.build();
   }
 
   static boolean dryRun(
diff --git a/java/com/google/gerrit/server/submit/MergeOneOp.java b/java/com/google/gerrit/server/submit/MergeOneOp.java
index f1b93e1..1840479 100644
--- a/java/com/google/gerrit/server/submit/MergeOneOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOneOp.java
@@ -29,9 +29,7 @@
 
   @Override
   public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
-    PersonIdent caller =
-        ctx.getIdentifiedUser()
-            .newCommitterIdent(args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
+    PersonIdent caller = ctx.getIdentifiedUser().newCommitterIdent(args.serverIdent);
     if (args.mergeTip.getCurrentTip() == null) {
       throw new IllegalStateException(
           "cannot merge commit "
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 942f024..58db331 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+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.toSet;
@@ -36,13 +37,14 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -70,6 +72,8 @@
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -88,7 +92,7 @@
 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.HashMap;
@@ -96,6 +100,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -242,11 +247,12 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
+  private final ProjectCache projectCache;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
 
-  private Timestamp ts;
+  private Instant ts;
   private SubmissionId submissionId;
   private IdentifiedUser caller;
 
@@ -254,7 +260,7 @@
   private CommitStatus commitStatus;
   private SubmitInput submitInput;
   private NotifyResolver.Result notify;
-  private Set<Project.NameKey> allProjects;
+  private Set<Project.NameKey> projects;
   private boolean dryrun;
   private TopicMetrics topicMetrics;
 
@@ -276,7 +282,8 @@
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
-      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
+      ProjectCache projectCache) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -294,6 +301,7 @@
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
+    this.projectCache = projectCache;
   }
 
   @Override
@@ -303,43 +311,47 @@
     }
   }
 
-  public static void checkSubmitRule(ChangeData cd, boolean allowClosed)
-      throws ResourceConflictException {
+  public static void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
       throw new ResourceConflictException("missing current patch set for change " + cd.getId());
     }
-    List<SubmitRecord> results = getSubmitRecords(cd, allowClosed);
-    if (SubmitRecord.allRecordsOK(results)) {
-      // Rules supplied a valid solution.
+    Map<SubmitRequirement, SubmitRequirementResult> srResults =
+        cd.submitRequirementsIncludingLegacy();
+    if (srResults.values().stream().allMatch(SubmitRequirementResult::fulfilled)) {
       return;
-    } else if (results.isEmpty()) {
+    } else if (srResults.isEmpty()) {
       throw new IllegalStateException(
           String.format(
-              "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
+              "Submit requirement results for change '%s' and patchset '%s' "
+                  + "are empty in project '%s'",
               cd.getId(), patchSet.id(), cd.change().getProject().get()));
     }
 
-    for (SubmitRecord record : results) {
-      switch (record.status) {
-        case OK:
+    for (SubmitRequirementResult srResult : srResults.values()) {
+      switch (srResult.status()) {
+        case SATISFIED:
+        case NOT_APPLICABLE:
+        case OVERRIDDEN:
+        case FORCED:
           break;
 
-        case CLOSED:
-          throw new ResourceConflictException("change is closed");
+        case ERROR:
+          throw new ResourceConflictException(
+              String.format(
+                  "submit requirement '%s' has an error: %s",
+                  srResult.submitRequirement().name(), srResult.errorMessage().orElse("")));
 
-        case RULE_ERROR:
-          throw new ResourceConflictException("submit rule error: " + record.errorMessage);
+        case UNSATISFIED:
+          throw new ResourceConflictException(
+              String.format(
+                  "submit requirement '%s' is unsatisfied.", srResult.submitRequirement().name()));
 
-        case NOT_READY:
-          throw new ResourceConflictException(describeNotReady(cd, record));
-
-        case FORCED:
         default:
           throw new IllegalStateException(
               String.format(
-                  "Unexpected SubmitRecord status %s for %s in %s",
-                  record.status, patchSet.id().getId(), cd.change().getProject().get()));
+                  "Unexpected submit requirement status %s for %s in %s",
+                  srResult.status().name(), patchSet.id().getId(), cd.change().getProject().get()));
       }
     }
     throw new IllegalStateException();
@@ -349,56 +361,8 @@
     return allowClosed ? SUBMIT_RULE_OPTIONS_ALLOW_CLOSED : SUBMIT_RULE_OPTIONS;
   }
 
-  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed) {
-    return cd.submitRecords(submitRuleOptions(allowClosed));
-  }
-
-  private static String describeNotReady(ChangeData cd, SubmitRecord record) {
-    List<String> blockingConditions = new ArrayList<>();
-    if (record.labels != null) {
-      blockingConditions.add(describeLabels(cd, record.labels));
-    }
-    if (record.requirements != null) {
-      record.requirements.stream()
-          .map(MergeOp::describeSubmitRequirement)
-          .forEach(blockingConditions::add);
-    }
-    return Joiner.on("; ").join(blockingConditions);
-  }
-
-  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels) {
-    List<String> labelResults = new ArrayList<>();
-    for (SubmitRecord.Label lbl : labels) {
-      switch (lbl.status) {
-        case OK:
-        case MAY:
-          break;
-
-        case REJECT:
-          labelResults.add("blocked by " + lbl.label);
-          break;
-
-        case NEED:
-          labelResults.add("needs " + lbl.label);
-          break;
-
-        case IMPOSSIBLE:
-          labelResults.add("needs " + lbl.label + " (check project access)");
-          break;
-
-        default:
-          throw new IllegalStateException(
-              String.format(
-                  "Unsupported SubmitRecord.Label %s for %s in %s",
-                  lbl, cd.change().currentPatchSetId(), cd.change().getProject()));
-      }
-    }
-    return Joiner.on("; ").join(labelResults);
-  }
-
-  private static String describeSubmitRequirement(LegacySubmitRequirement legacySubmitRequirement) {
-    return String.format(
-        "Submit requirement not fulfilled: %s", legacySubmitRequirement.fallbackText());
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd) {
+    return cd.submitRecords(submitRuleOptions(/* allowClosed= */ false));
   }
 
   private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
@@ -415,7 +379,7 @@
         } else if (cd.change().isWorkInProgress()) {
           commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
-          checkSubmitRule(cd, allowMerged);
+          checkSubmitRequirements(cd);
         }
       } catch (ResourceConflictException e) {
         commitStatus.problem(cd.getId(), e.getMessage());
@@ -428,15 +392,32 @@
     commitStatus.maybeFailVerbose();
   }
 
-  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) {
+  private void bypassSubmitRulesAndRequirements(ChangeSet cs) {
     checkArgument(
         !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
-      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd, allowClosed));
+      Change change = cd.change();
+      if (change == null) {
+        throw new StorageException("Change not found");
+      }
+      if (change.isClosed()) {
+        // No need to check submit rules if the change is closed.
+        continue;
+      }
+      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd));
       SubmitRecord forced = new SubmitRecord();
       forced.status = SubmitRecord.Status.FORCED;
       records.add(forced);
-      cd.setSubmitRecords(submitRuleOptions(allowClosed), records);
+      cd.setSubmitRecords(submitRuleOptions(/* allowClosed= */ false), records);
+
+      // Also bypass submit requirements. Mark them as forced.
+      Map<SubmitRequirement, SubmitRequirementResult> forcedSRs =
+          cd.submitRequirementsIncludingLegacy().entrySet().stream()
+              .collect(
+                  Collectors.toMap(
+                      Map.Entry::getKey,
+                      entry -> entry.getValue().toBuilder().forced(Optional.of(true)).build()));
+      cd.setSubmitRequirements(forcedSRs);
     }
   }
 
@@ -470,7 +451,7 @@
             firstNonNull(submitInput.notify, NotifyHandling.ALL), submitInput.notifyDetails);
     this.dryrun = dryrun;
     this.caller = caller;
-    this.ts = TimeUtil.nowTs();
+    this.ts = TimeUtil.now();
     this.submissionId = new SubmissionId(change);
 
     try (TraceContext traceContext =
@@ -481,7 +462,9 @@
       logger.atFine().log("Beginning integration of %s", change);
       try {
         ChangeSet indexBackedChangeSet =
-            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
+            mergeSuperSet
+                .setMergeOpRepoManager(orm)
+                .completeChangeSet(change, caller, /* includingTopicClosure= */ false);
         if (!indexBackedChangeSet.ids().contains(change.getId())) {
           // indexBackedChangeSet contains only open changes, if the change is missing in this set
           // it might be that the change was concurrently submitted in the meantime.
@@ -509,7 +492,7 @@
           if (!changeData.change().getStatus().equals(Status.NEW)) {
             logger.atFine().log(
                 "Change %s has status %s due to stale index, so it is skipped during submit",
-                changeData.getId().toString(), changeData.change().getStatus().name());
+                changeData.getId(), changeData.change().getStatus().name());
             continue;
           }
           filteredChanges.add(changeData);
@@ -538,7 +521,7 @@
                   boolean isRetry = attempt > 1;
                   if (isRetry) {
                     logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
-                    this.ts = TimeUtil.nowTs();
+                    this.ts = TimeUtil.now();
                     openRepoManager();
                   }
                   this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
@@ -547,9 +530,10 @@
                     checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
                   } else {
                     logger.atFine().log("Bypassing submit rules");
-                    bypassSubmitRules(filteredNoteDbChangeSet, isRetry);
+                    bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
                   }
-                  integrateIntoHistory(filteredNoteDbChangeSet, submissionExecutor);
+                  integrateIntoHistory(
+                      filteredNoteDbChangeSet, submissionExecutor, checkSubmitRules);
                   return null;
                 })
             .listener(retryTracker)
@@ -627,7 +611,8 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs, SubmissionExecutor submissionExecutor)
+  private void integrateIntoHistory(
+      ChangeSet cs, SubmissionExecutor submissionExecutor, boolean checkSubmitRules)
       throws RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logger.atFine().log("Beginning merge attempt on %s", cs);
@@ -658,17 +643,23 @@
       List<SubmitStrategy> strategies =
           getSubmitStrategies(
               toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
-      this.allProjects = updateOrderCalculator.getProjectsInOrder();
-      List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
+      this.projects = updateOrderCalculator.getProjectsInOrder();
+      List<BatchUpdate> batchUpdates =
+          orm.batchUpdates(
+              projects, /* refLogMessage= */ checkSubmitRules ? "merged" : "forced-merge");
       // Group batch updates by project
       Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
           batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
       for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
         Project.NameKey project = entry.getValue().project();
         Change.Id changeId = entry.getKey();
+        ChangeData cd = entry.getValue();
+        Collection<SubmitRequirementResult> srResults =
+            cd.submitRequirementsIncludingLegacy().values();
         batchUpdatesByProject
             .get(project)
-            .addOp(changeId, storeSubmitRequirementsOpFactory.create());
+            .addOp(changeId, storeSubmitRequirementsOpFactory.create(srResults, cd));
+        crossCheckSubmitRequirementResults(cd, srResults, project);
       }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
@@ -682,7 +673,7 @@
 
         // Do not leave executed BatchUpdates in the OpenRepos
         if (!dryrun) {
-          orm.resetUpdates(ImmutableSet.copyOf(this.allProjects));
+          orm.resetUpdates(ImmutableSet.copyOf(this.projects));
         }
       }
     } catch (NoSuchProjectException e) {
@@ -711,12 +702,12 @@
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
-      throw new InternalServerWithUserMessageException(genericMergeError(cs), e);
+      throw new MergeUpdateException(genericMergeError(cs), e);
     }
   }
 
   public Set<Project.NameKey> getAllProjects() {
-    return allProjects;
+    return projects;
   }
 
   public MergeOpRepoManager getMergeOpRepoManager() {
@@ -1018,4 +1009,28 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
+
+  /**
+   * Make sure that for every project config submit requirement there exists a corresponding result
+   * with the same name in {@code srResults}. If no result is found, log a warning message.
+   */
+  private void crossCheckSubmitRequirementResults(
+      ChangeData cd, Collection<SubmitRequirementResult> srResults, Project.NameKey project) {
+    ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
+    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+    for (String srName : projectConfigRequirements.keySet()) {
+      boolean hasResult = false;
+      for (SubmitRequirementResult srResult : srResults) {
+        if (!srResult.isLegacy() && srResult.submitRequirement().name().equals(srName)) {
+          hasResult = true;
+          break;
+        }
+      }
+      if (!hasResult) {
+        logger.atWarning().log(
+            "Change %d: No result found for project config submit requirement '%s'",
+            cd.getId().get(), srName);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 8981b07..0a74a07 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -37,7 +37,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -167,7 +167,7 @@
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
 
-  private Timestamp ts;
+  private Instant ts;
   private IdentifiedUser caller;
   private NotifyResolver.Result notify;
 
@@ -185,7 +185,7 @@
     openRepos = new HashMap<>();
   }
 
-  public void setContext(Timestamp ts, IdentifiedUser caller, NotifyResolver.Result notify) {
+  public void setContext(Instant ts, IdentifiedUser caller, NotifyResolver.Result notify) {
     this.ts = requireNonNull(ts);
     this.caller = requireNonNull(caller);
     this.notify = requireNonNull(notify);
@@ -206,11 +206,12 @@
     }
   }
 
-  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects)
+  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects, String refLogMessage)
       throws NoSuchProjectException, IOException {
+    requireNonNull(refLogMessage, "refLogMessage");
     List<BatchUpdate> updates = new ArrayList<>(projects.size());
     for (Project.NameKey project : projects) {
-      updates.add(getRepo(project).getUpdate().setNotify(notify).setRefLogMessage("merged"));
+      updates.add(getRepo(project).getUpdate().setNotify(notify).setRefLogMessage(refLogMessage));
     }
     return updates;
   }
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 67f2907..8581e20 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -92,7 +92,19 @@
     return this;
   }
 
-  public ChangeSet completeChangeSet(Change change, CurrentUser user)
+  /**
+   * Gets the ChangeSet of this {@code change} based on visiblity of the {@code user}. if
+   * change.submitWholeTopic is true, we return the topic closure as well as the dependent changes
+   * of the topic closure. Otherwise, we return just the dependent changes.
+   *
+   * @param change the change for which we get the dependent changes / topic closure.
+   * @param user the current user for visibility purposes.
+   * @param includingTopicClosure when true, return as if change.submitWholeTopic = true, so we
+   *     return the topic closure.
+   * @return {@link ChangeSet} object that represents the dependent changes and/or topic closure of
+   *     the requested change.
+   */
+  public ChangeSet completeChangeSet(Change change, CurrentUser user, boolean includingTopicClosure)
       throws IOException, PermissionBackendException {
     try {
       if (orm == null) {
@@ -113,7 +125,7 @@
       }
 
       ChangeSet changeSet = new ChangeSet(cd, visible);
-      if (wholeTopicEnabled(cfg)) {
+      if (wholeTopicEnabled(cfg) || includingTopicClosure) {
         return completeChangeSetIncludingTopics(changeSet, user);
       }
       try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 355d25f..cfb2f88 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -56,7 +55,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted;
     try {
       sorted = args.rebaseSorter.sort(toMerge);
@@ -92,12 +91,13 @@
         // found a merge commit that depends on a normal change, this means we are required to merge
         // the whole series at once
         sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toList());
+        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toImmutableList());
       }
       foundNonMerge = true;
     }
 
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
@@ -114,7 +114,7 @@
       }
       first = false;
     }
-    return ops;
+    return ops.build();
   }
 
   private class RebaseRootOp extends SubmitStrategyOp {
@@ -166,8 +166,7 @@
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer =
-            args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+        PersonIdent committer = ctx.newCommitterIdent(args.caller);
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
@@ -304,8 +303,7 @@
           && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
-        PersonIdent caller =
-            ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
+        PersonIdent caller = ctx.newCommitterIdent();
         CodeReviewCommit newTip =
             args.mergeUtil.mergeOneCommit(
                 caller,
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index bcd7923..cee0ad9 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -152,7 +152,7 @@
       case INHERIT:
       default:
         String errorMsg = "No submit strategy for: " + submitType;
-        logger.atSevere().log(errorMsg);
+        logger.atSevere().log("%s", errorMsg);
         throw new StorageException(errorMsg);
     }
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 6291e6c..83c6634 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -18,6 +18,7 @@
 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.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.BranchNameKey;
@@ -293,5 +294,5 @@
     }
   }
 
-  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge);
+  protected abstract ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge);
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 2e66ae2..3bd26dc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -90,7 +90,7 @@
       case INHERIT:
       default:
         String errorMsg = "No submit strategy for: " + submitType;
-        logger.atSevere().log(errorMsg);
+        logger.atSevere().log("%s", errorMsg);
         throw new StorageException(errorMsg);
     }
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index f26ec17..d06940c 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -286,7 +286,7 @@
       setMerged(ctx, commit, message(ctx, commit, s));
     } catch (StorageException err) {
       String msg = "Error updating change status for " + id;
-      logger.atSevere().withCause(err).log(msg);
+      logger.atSevere().withCause(err).log("%s", msg);
       args.commitStatus.logProblem(id, msg);
       // It's possible this happened before updating anything in the db, but
       // it's hard to know for sure, so just return true below to be safe.
@@ -327,7 +327,7 @@
         ctx.getRevWalk(), ctx.getUpdate(psId), psId, alreadyMergedCommit, groups, null, null);
   }
 
-  private void setApproval(ChangeContext ctx, IdentifiedUser user) throws IOException {
+  private void setApproval(ChangeContext ctx, IdentifiedUser user) {
     Change.Id id = ctx.getChange().getId();
     List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
@@ -348,13 +348,10 @@
     }
   }
 
-  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws IOException {
+  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update) {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
-    for (PatchSetApproval psa :
-        args.approvalsUtil.byPatchSet(
-            ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+    for (PatchSetApproval psa : args.approvalsUtil.byPatchSet(ctx.getNotes(), psId)) {
       byKey.put(psa.key(), psa);
     }
 
@@ -521,19 +518,29 @@
     }
   }
 
-  /** See {@link #updateRepo(RepoContext)} */
+  /**
+   * See {@link #updateRepo(RepoContext)}
+   *
+   * @param ctx context for the repository update
+   */
   protected void updateRepoImpl(RepoContext ctx) throws Exception {}
 
   /**
    * Returns a new patch set if one was created by the submit strategy, or null if not
    *
    * <p>See {@link #updateChange(ChangeContext)}
+   *
+   * @param ctx context for the change update
    */
   protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
     return null;
   }
 
-  /** See {@link #postUpdate(PostUpdateContext)} */
+  /**
+   * See {@link #postUpdate(PostUpdateContext)}
+   *
+   * @param ctx context for the post update
+   */
   protected void postUpdateImpl(PostUpdateContext ctx) throws Exception {}
 
   /** Amend the commit with gitlink update */
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
index 1312a4b..37df66b 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleCommits.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -36,7 +36,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 69d76e2..ba736fa 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -103,7 +103,10 @@
           }
         }
       }
-      BatchUpdate.execute(orm.batchUpdates(superProjects), ImmutableList.of(), dryrun);
+      BatchUpdate.execute(
+          orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
+          ImmutableList.of(),
+          dryrun);
     } catch (UpdateException | IOException | NoSuchProjectException e) {
       throw new StorageException("Cannot update gitlinks", e);
     }
diff --git a/java/com/google/gerrit/server/tools/ToolsCatalog.java b/java/com/google/gerrit/server/tools/ToolsCatalog.java
index 9c1483f..015b8f1 100644
--- a/java/com/google/gerrit/server/tools/ToolsCatalog.java
+++ b/java/com/google/gerrit/server/tools/ToolsCatalog.java
@@ -31,7 +31,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -46,7 +46,7 @@
 public class ToolsCatalog {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final SortedMap<String, Entry> toc;
+  private final NavigableMap<String, Entry> toc;
 
   @Inject
   ToolsCatalog() throws IOException {
@@ -73,8 +73,8 @@
     return toc.get(name);
   }
 
-  private static SortedMap<String, Entry> readToc() throws IOException {
-    SortedMap<String, Entry> toc = new TreeMap<>();
+  private static NavigableMap<String, Entry> readToc() throws IOException {
+    NavigableMap<String, Entry> toc = new TreeMap<>();
     final BufferedReader br =
         new BufferedReader(new InputStreamReader(new ByteArrayInputStream(read("TOC")), UTF_8));
     String line;
@@ -108,7 +108,7 @@
     }
     toc.put(top.getPath(), top);
 
-    return Collections.unmodifiableSortedMap(toc);
+    return Collections.unmodifiableNavigableMap(toc);
   }
 
   @Nullable
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index b88d0c0..77f3de4 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -68,7 +68,7 @@
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -122,7 +122,7 @@
   }
 
   public interface Factory {
-    BatchUpdate create(Project.NameKey project, CurrentUser user, Timestamp when);
+    BatchUpdate create(Project.NameKey project, CurrentUser user, Instant when);
   }
 
   public static void execute(
@@ -252,7 +252,7 @@
     }
 
     @Override
-    public Timestamp getWhen() {
+    public Instant getWhen() {
       return when;
     }
 
@@ -311,14 +311,9 @@
 
     @Override
     public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      return getUpdate(psId, when);
-    }
-
-    @Override
-    public ChangeUpdate getUpdate(PatchSet.Id psId, Timestamp whenOverride) {
       ChangeUpdate u = defaultUpdates.get(psId);
       if (u == null) {
-        u = getNewChangeUpdate(psId, whenOverride);
+        u = getNewChangeUpdate(psId);
         defaultUpdates.put(psId, u);
       }
       return u;
@@ -326,18 +321,13 @@
 
     @Override
     public ChangeUpdate getDistinctUpdate(PatchSet.Id psId) {
-      return getDistinctUpdate(psId, when);
-    }
-
-    @Override
-    public ChangeUpdate getDistinctUpdate(PatchSet.Id psId, Timestamp whenOverride) {
-      ChangeUpdate u = getNewChangeUpdate(psId, whenOverride);
+      ChangeUpdate u = getNewChangeUpdate(psId);
       distinctUpdates.put(psId, u);
       return u;
     }
 
-    private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId, Timestamp whenOverride) {
-      ChangeUpdate u = changeUpdateFactory.create(notes, user, whenOverride);
+    private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = changeUpdateFactory.create(notes, user, when);
       if (newChanges.containsKey(notes.getChangeId())) {
         u.setAllowWriteToNewRef(true);
       }
@@ -386,7 +376,7 @@
 
   private final Project.NameKey project;
   private final CurrentUser user;
-  private final Timestamp when;
+  private final Instant when;
   private final TimeZone tz;
 
   private final ListMultimap<Change.Id, BatchUpdateOp> ops =
@@ -415,7 +405,7 @@
       GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
+      @Assisted Instant when) {
     this.repoManager = repoManager;
     this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
@@ -634,12 +624,13 @@
       BatchUpdate.this.executed = manager.isExecuted();
     }
 
-    List<ListenableFuture<ChangeData>> startIndexFutures() {
+    ImmutableList<ListenableFuture<ChangeData>> startIndexFutures() {
       if (dryrun) {
         return ImmutableList.of();
       }
       logDebug("Reindexing %d changes", results.size());
-      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>(results.size());
+      ImmutableList.Builder<ListenableFuture<ChangeData>> indexFutures =
+          ImmutableList.builderWithExpectedSize(results.size());
       for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
         Change.Id id = e.getKey();
         switch (e.getValue()) {
@@ -655,7 +646,7 @@
             throw new IllegalStateException("unexpected result: " + e.getValue());
         }
       }
-      return indexFutures;
+      return indexFutures.build();
     }
   }
 
@@ -755,7 +746,7 @@
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
     if (RequestId.isSet()) {
-      logger.atFine().log(msg);
+      logger.atFine().log("%s", msg);
     }
   }
 
diff --git a/java/com/google/gerrit/server/update/ChangeContext.java b/java/com/google/gerrit/server/update/ChangeContext.java
index feba541..aeabde4 100644
--- a/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/java/com/google/gerrit/server/update/ChangeContext.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import java.sql.Timestamp;
 
 /**
  * Context for performing the {@link BatchUpdateOp#updateChange} phase.
@@ -44,15 +43,6 @@
   ChangeUpdate getUpdate(PatchSet.Id psId);
 
   /**
-   * Same as {@link ChangeContext#getUpdate}, but allows to override the commit timestamp.
-   *
-   * @param psId patch set ID.
-   * @param whenOverride commit timestamp.
-   * @return handle for change updates.
-   */
-  ChangeUpdate getUpdate(PatchSet.Id psId, Timestamp whenOverride);
-
-  /**
    * Gets a new ChangeUpdate for this change at a given patch set.
    *
    * <p>To get the current patch set ID, use {@link com.google.gerrit.server.PatchSetUtil#current}.
@@ -63,15 +53,6 @@
   ChangeUpdate getDistinctUpdate(PatchSet.Id psId);
 
   /**
-   * Same as {@link ChangeContext#getDistinctUpdate}, but allows to override the commit timestamp.
-   *
-   * @param psId patch set ID.
-   * @param whenOverride commit timestamp.
-   * @return handle for change updates.
-   */
-  ChangeUpdate getDistinctUpdate(PatchSet.Id psId, Timestamp whenOverride);
-
-  /**
    * Get the up-to-date notes for this change.
    *
    * <p>The change data is read within the same transaction that {@link
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index 9947168..57ebedd 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -24,8 +24,9 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.TimeZone;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -66,7 +67,7 @@
    *
    * @return timestamp.
    */
-  Timestamp getWhen();
+  Instant getWhen();
 
   /**
    * Get the time zone in which this update takes place.
@@ -134,4 +135,33 @@
   default Account.Id getAccountId() {
     return getIdentifiedUser().getAccountId();
   }
+
+  /**
+   * Creates a new {@link PersonIdent} with {@link #getWhen()} as timestamp.
+   *
+   * @param personIdent {@link PersonIdent} to be copied
+   * @return copied {@link PersonIdent} with {@link #getWhen()} as timestamp
+   */
+  default PersonIdent newPersonIdent(PersonIdent personIdent) {
+    return new PersonIdent(personIdent, getWhen().toEpochMilli(), personIdent.getTimeZoneOffset());
+  }
+
+  /**
+   * Creates a committer {@link PersonIdent} for {@link #getIdentifiedUser()}.
+   *
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent() {
+    return newCommitterIdent(getIdentifiedUser());
+  }
+
+  /**
+   * Creates a committer {@link PersonIdent} for the given user.
+   *
+   * @param user user for which a committer {@link PersonIdent} should be created
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent(IdentifiedUser user) {
+    return user.newCommitterIdent(getWhen(), getTimeZone());
+  }
 }
diff --git a/java/com/google/gerrit/server/util/AccountTemplateUtil.java b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
index c552ce8..93d7086 100644
--- a/java/com/google/gerrit/server/util/AccountTemplateUtil.java
+++ b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
@@ -24,9 +24,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.HashSet;
 import java.util.Optional;
-import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -62,7 +60,7 @@
       return ImmutableSet.of();
     }
     Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(textTemplate);
-    Set<Account.Id> accountsInTemplate = new HashSet<>();
+    ImmutableSet.Builder<Account.Id> accountsInTemplate = ImmutableSet.builder();
     while (matcher.find()) {
       String accountId = matcher.group(1);
       Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
@@ -72,7 +70,7 @@
         logger.atFine().log("Failed to parse accountId from template %s", matcher.group());
       }
     }
-    return ImmutableSet.copyOf(accountsInTemplate);
+    return accountsInTemplate.build();
   }
 
   public static String getAccountTemplate(Account.Id accountId) {
@@ -82,7 +80,7 @@
   /** Builds user-readable text from text, that might contain {@link #ACCOUNT_TEMPLATE}. */
   public String replaceTemplates(String messageTemplate) {
     Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
-    StringBuffer out = new StringBuffer();
+    StringBuilder out = new StringBuilder();
     while (matcher.find()) {
       String accountId = matcher.group(1);
       String unrecognizedAccount = "Unrecognized Gerrit Account " + accountId;
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 9238b44..26c8f47 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -43,6 +42,14 @@
         .collect(ImmutableSet.toImmutableSet());
   }
 
+  /** Returns only updates where the user was removed. */
+  public static ImmutableSet<AttentionSetUpdate> removalsOnly(
+      Collection<AttentionSetUpdate> updates) {
+    return updates.stream()
+        .filter(u -> u.operation() == Operation.REMOVE)
+        .collect(ImmutableSet.toImmutableSet());
+  }
+
   /**
    * Validates the input for AttentionSetInput. This must be called for all inputs that relate to
    * adding or removing attention set entries, except for {@link
@@ -115,7 +122,7 @@
             : null;
     return new AttentionSetInfo(
         accountLoader.get(attentionSetUpdate.account()),
-        Timestamp.from(attentionSetUpdate.timestamp()),
+        attentionSetUpdate.timestamp(),
         attentionSetUpdate.reason(),
         reasonAccount);
   }
diff --git a/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
index d4c2dc4..1534ef3 100644
--- a/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/java/com/google/gerrit/server/util/IdGenerator.java
@@ -62,7 +62,7 @@
   private static short hi16(int in) {
     return (short)
         ( //
-        ((in >>> 24 & 0xff))
+        (in >>> 24 & 0xff)
             | //
             ((in >>> 16 & 0xff) << 8) //
         );
@@ -71,7 +71,7 @@
   private static short lo16(int in) {
     return (short)
         ( //
-        ((in >>> 8 & 0xff))
+        (in >>> 8 & 0xff)
             | //
             ((in & 0xff) << 8) //
         );
diff --git a/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
index ac33902..ef5e67f 100644
--- a/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.server.project.RefPattern;
 import java.util.Comparator;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.text.similarity.LevenshteinDistance;
 
 /**
  * Order the Ref Pattern by the most specific. This sort is done by:
@@ -89,7 +89,7 @@
     } else {
       return Math.max(pattern.length(), refName.length());
     }
-    return StringUtils.getLevenshteinDistance(example, refName);
+    return LevenshteinDistance.getDefaultInstance().apply(example, refName);
   }
 
   private boolean finite(String pattern) {
diff --git a/java/com/google/gerrit/server/util/RequestScopePropagator.java b/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 10c46fc..2f03b07 100644
--- a/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -76,7 +76,7 @@
   public final <T> Callable<T> wrap(Callable<T> callable) {
     final RequestContext callerContext = requireNonNull(local.getContext());
     final Callable<T> wrapped = wrapImpl(context(callerContext, cleanup(callable)));
-    return new Callable<T>() {
+    return new Callable<>() {
       @Override
       public T call() throws Exception {
         if (callerContext == local.getContext()) {
@@ -169,7 +169,7 @@
 
   protected <T> Callable<T> context(RequestContext context, Callable<T> callable) {
     return () -> {
-      RequestContext old = local.setContext(context::getUser);
+      RequestContext old = local.setContext(context);
       try {
         return callable.call();
       } finally {
diff --git a/java/com/google/gerrit/server/util/TreeFormatter.java b/java/com/google/gerrit/server/util/TreeFormatter.java
index 49d4a55..5a898d5 100644
--- a/java/com/google/gerrit/server/util/TreeFormatter.java
+++ b/java/com/google/gerrit/server/util/TreeFormatter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.util;
 
 import java.io.PrintWriter;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 
 public class TreeFormatter {
 
@@ -24,7 +24,7 @@
 
     boolean isVisible();
 
-    SortedSet<? extends TreeNode> getChildren();
+    NavigableSet<? extends TreeNode> getChildren();
   }
 
   public static final String NOT_VISIBLE_NODE = "(x)";
@@ -40,7 +40,7 @@
     this.stdout = stdout;
   }
 
-  public void printTree(SortedSet<? extends TreeNode> rootNodes) {
+  public void printTree(NavigableSet<? extends TreeNode> rootNodes) {
     if (rootNodes.isEmpty()) {
       return;
     }
@@ -66,7 +66,7 @@
 
   private void printTree(TreeNode node, int level, boolean isLast) {
     printNode(node, level, isLast);
-    final SortedSet<? extends TreeNode> childNodes = node.getChildren();
+    final NavigableSet<? extends TreeNode> childNodes = node.getChildren();
     int i = 0;
     final int size = childNodes.size();
     for (TreeNode childNode : childNodes) {
diff --git a/java/com/google/gerrit/server/util/time/BUILD b/java/com/google/gerrit/server/util/time/BUILD
index b1126f0..f3e0091 100644
--- a/java/com/google/gerrit/server/util/time/BUILD
+++ b/java/com/google/gerrit/server/util/time/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/server/util/git",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
index 54ef305..f89324b 100644
--- a/java/com/google/gerrit/server/util/time/TimeUtil.java
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -15,10 +15,7 @@
 package com.google.gerrit.server.util.time;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.util.git.DelegateSystemReader;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
@@ -44,23 +41,8 @@
     return Instant.ofEpochMilli(nowMs());
   }
 
-  public static Timestamp nowTs() {
-    return new Timestamp(nowMs());
-  }
-
-  /**
-   * Returns the magic timestamp representing no specific time.
-   *
-   * <p>This "null object" is helpful in contexts where using {@code null} directly is not possible.
-   */
-  @UsedAt(Project.PLUGIN_CHECKS)
-  public static Timestamp never() {
-    // Always create a new object as timestamps are mutable.
-    return new Timestamp(0);
-  }
-
-  public static Timestamp truncateToSecond(Timestamp t) {
-    return new Timestamp((t.getTime() / 1000) * 1000);
+  public static Instant truncateToSecond(Instant t) {
+    return Instant.ofEpochMilli(t.getEpochSecond() * 1000);
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/sshd/AliasCommand.java b/java/com/google/gerrit/sshd/AliasCommand.java
index bf0dd91..17ea463 100644
--- a/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/java/com/google/gerrit/sshd/AliasCommand.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.io.IOException;
-import java.util.LinkedList;
+import java.util.ArrayDeque;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
@@ -120,8 +120,8 @@
     }
   }
 
-  private static LinkedList<String> chain(CommandName command) {
-    LinkedList<String> chain = new LinkedList<>();
+  private static Iterable<String> chain(CommandName command) {
+    ArrayDeque<String> chain = new ArrayDeque<>();
     while (command != null) {
       chain.addFirst(command.value());
       command = Commands.parentOf(command);
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index f3bd5e1..af7078d 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -4,7 +4,6 @@
     name = "sshd",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
-    runtime_deps = ["//lib:jsch"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index f1be04e..c1c58c8 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -104,7 +104,7 @@
 
   @Inject protected Injector injector;
 
-  @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+  @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
@@ -398,7 +398,7 @@
       isZeroLength = Arrays.stream(stackTrace).anyMatch(s -> s.toString().contains(zeroLength));
     }
     if (!isZeroLength) {
-      logger.atSevere().withCause(e).log(message.toString());
+      logger.atSevere().withCause(e).log("%s", message);
     }
   }
 
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index bb5494b..92de012 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -191,6 +191,12 @@
                 }
 
                 @Override
+                public void onExit(int rc, String exitMessage) {
+                  exit.onExit(translateExit(rc), exitMessage);
+                  log(rc, exitMessage);
+                }
+
+                @Override
                 public void onExit(int rc) {
                   exit.onExit(translateExit(rc));
                   log(rc);
diff --git a/java/com/google/gerrit/sshd/DispatchCommandProvider.java b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
index 2a65ed0..acf2df9 100644
--- a/java/com/google/gerrit/sshd/DispatchCommandProvider.java
+++ b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
@@ -91,7 +91,7 @@
     return m;
   }
 
-  private static final TypeLiteral<Command> type = new TypeLiteral<Command>() {};
+  private static final TypeLiteral<Command> type = new TypeLiteral<>() {};
 
   private List<Binding<Command>> allCommands() {
     return injector.findBindingsByType(type);
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index f9d0769..cc35a32 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -643,7 +643,7 @@
           msg.append(avail[i].getName());
         }
         msg.append(" is supported");
-        logger.atSevere().log(msg.toString());
+        logger.atSevere().log("%s", msg);
       } else if (add) {
         if (!def.contains(n)) {
           def.add(n);
diff --git a/java/com/google/gerrit/sshd/SshLogJsonLayout.java b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
index fca0a5a..321cf56 100644
--- a/java/com/google/gerrit/sshd/SshLogJsonLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
@@ -26,11 +26,14 @@
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.util.logging.JsonLayout;
 import com.google.gerrit.util.logging.JsonLogEntry;
+import java.util.List;
 import org.apache.log4j.spi.LoggingEvent;
 
 public class SshLogJsonLayout extends JsonLayout {
+  private static final Splitter SPLITTER = Splitter.on(" ");
 
   @Override
   public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
@@ -81,18 +84,18 @@
 
       String metricString = getMdcString(event, P_MESSAGE);
       if (metricString != null && !metricString.isEmpty()) {
-        String[] ssh_metrics = metricString.split(" ");
-        this.timeNegotiating = ssh_metrics[0];
-        this.timeSearchReuse = ssh_metrics[1];
-        this.timeSearchSizes = ssh_metrics[2];
-        this.timeCounting = ssh_metrics[3];
-        this.timeCompressing = ssh_metrics[4];
-        this.timeWriting = ssh_metrics[5];
-        this.timeTotal = ssh_metrics[6];
-        this.bitmapIndexMisses = ssh_metrics[7];
-        this.deltasTotal = ssh_metrics[8];
-        this.objectsTotal = ssh_metrics[9];
-        this.bytesTotal = ssh_metrics[10];
+        List<String> ssh_metrics = SPLITTER.splitToList(metricString);
+        this.timeNegotiating = ssh_metrics.get(0);
+        this.timeSearchReuse = ssh_metrics.get(1);
+        this.timeSearchSizes = ssh_metrics.get(2);
+        this.timeCounting = ssh_metrics.get(3);
+        this.timeCompressing = ssh_metrics.get(4);
+        this.timeWriting = ssh_metrics.get(5);
+        this.timeTotal = ssh_metrics.get(6);
+        this.bitmapIndexMisses = ssh_metrics.get(7);
+        this.deltasTotal = ssh_metrics.get(8);
+        this.objectsTotal = ssh_metrics.get(9);
+        this.bytesTotal = ssh_metrics.get(10);
       }
     }
   }
diff --git a/java/com/google/gerrit/sshd/SshLogLayout.java b/java/com/google/gerrit/sshd/SshLogLayout.java
index a1f2c40..bb7edfa 100644
--- a/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -32,7 +32,7 @@
 import org.eclipse.jgit.util.QuotedString;
 
 public final class SshLogLayout extends Layout {
-  protected final LogTimestampFormatter timestampFormatter;
+  private final LogTimestampFormatter timestampFormatter;
 
   public SshLogLayout() {
     timestampFormatter = new LogTimestampFormatter();
@@ -40,7 +40,7 @@
 
   @Override
   public String format(LoggingEvent event) {
-    final StringBuffer buf = new StringBuffer(128);
+    final StringBuilder buf = new StringBuilder(128);
 
     buf.append('[');
     buf.append(timestampFormatter.format(event.getTimeStamp()));
@@ -75,7 +75,7 @@
     return buf.toString();
   }
 
-  private void req(String key, StringBuffer buf, LoggingEvent event) {
+  private void req(String key, StringBuilder buf, LoggingEvent event) {
     Object val = event.getMDC(key);
     buf.append(' ');
     if (val != null) {
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index b85cf6d..f19c395 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -223,7 +223,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/sshd/SshSessionFactoryInitializer.java b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
index 1cdf923..de91b68 100644
--- a/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
+++ b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.server.config.SshClientImplementation.APACHE;
-
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.SshSessionFactory;
 import org.eclipse.jgit.transport.sshd.DefaultProxyDataFactory;
 import org.eclipse.jgit.transport.sshd.JGitKeyCache;
@@ -24,13 +21,11 @@
 import org.eclipse.jgit.util.FS;
 
 public class SshSessionFactoryInitializer {
-  public static void init(Config config) {
-    if (APACHE == config.getEnum("ssh", null, "clientImplementation", APACHE)) {
-      SshdSessionFactory factory =
-          new SshdSessionFactory(new JGitKeyCache(), new DefaultProxyDataFactory());
-      factory.setHomeDirectory(FS.DETECTED.userHome());
-      SshSessionFactory.setInstance(factory);
-    }
+  public static void init() {
+    SshdSessionFactory factory =
+        new SshdSessionFactory(new JGitKeyCache(), new DefaultProxyDataFactory());
+    factory.setHomeDirectory(FS.DETECTED.userHome());
+    SshSessionFactory.setInstance(factory);
   }
 
   private SshSessionFactoryInitializer() {}
diff --git a/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java b/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java
deleted file mode 100644
index eacec28..0000000
--- a/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// 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.sshd.commands;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.approval.RecursiveApprovalCopier;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-@CommandMetaData(
-    name = "copy-approvals",
-    description = "Copy inferred approvals labels to the latest patch-set")
-@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
-public class CopyApprovalsCommand extends SshCommand {
-
-  private final Set<Project.NameKey> projects = new HashSet<>();
-  private final RecursiveApprovalCopier recursiveApprovalCopier;
-  private final GitRepositoryManager repositoryManager;
-
-  @Argument(
-      index = 0,
-      required = false,
-      multiValued = true,
-      metaVar = "PROJECT",
-      usage = "list of projects to scan for approvals (default: all projects)")
-  void addProject(String project) {
-    projects.add(Project.nameKey(project));
-  }
-
-  @Option(
-      name = "--verbose",
-      aliases = "-v",
-      usage = "display projects/changes impacted by the label copy operation",
-      metaVar = "VERBOSE")
-  private boolean verbose;
-
-  @Inject
-  public CopyApprovalsCommand(
-      RecursiveApprovalCopier recursiveApprovalCopier, GitRepositoryManager repositoryManager) {
-    this.recursiveApprovalCopier = recursiveApprovalCopier;
-    this.repositoryManager = repositoryManager;
-  }
-
-  @Override
-  protected void run() throws Exception {
-    AtomicInteger changesCounter = new AtomicInteger();
-    stdout.println(
-        "Copying inferred approvals labels on " + (projects.isEmpty() ? "all projects" : projects));
-
-    Set<Project.NameKey> projectsList = projects.isEmpty() ? repositoryManager.list() : projects;
-
-    for (Project.NameKey project : projectsList) {
-      stdout.print("> " + project + " : ");
-      recursiveApprovalCopier.persist(
-          project,
-          c -> {
-            if (verbose) {
-              stdout.println("  [" + c.getProject() + "," + c.getChangeId() + "] updated");
-            }
-            changesCounter.incrementAndGet();
-          });
-      stdout.println("DONE");
-    }
-
-    stdout.println(
-        "Labels copied for "
-            + projectsList.size()
-            + " project(s) have impacted "
-            + changesCounter.get()
-            + " change(s)");
-  }
-}
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 1a08c43..e7fe22f 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -109,7 +109,6 @@
 
     command(gerrit, RenameGroupCommand.class);
     command(gerrit, ReviewCommand.class);
-    command(gerrit, CopyApprovalsCommand.class);
     command(gerrit, SetProjectCommand.class);
     command(gerrit, SetReviewersCommand.class);
     command(gerrit, SetTopicCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
index 29cc1cf..aa147f0 100644
--- a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
@@ -41,10 +41,13 @@
 
     if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
       die(
-          "External IDs online migration requires auth.userNameCaseInsensitive and auth.userNameCaseInsensitiveMigrationMode to be set to true. Cannot start migration!");
+          "External IDs online migration requires auth.userNameCaseInsensitive and"
+              + " auth.userNameCaseInsensitiveMigrationMode to be set to true. Cannot start"
+              + " migration!");
     }
     onlineExternalIdCaseSensivityMigrator.migrate();
     stdout.println(
-        "External ids case insensitivity migration started. To check if it's completed look for \"External IDs migration completed!\" message in the Gerrit server logs");
+        "External ids case insensitivity migration started. To check if it's completed look for"
+            + " \"External IDs migration completed!\" message in the Gerrit server logs");
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index 1a7be32..742536c 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import java.util.Enumeration;
+import java.util.Collections;
 import java.util.Map;
 import java.util.TreeMap;
 import org.apache.log4j.LogManager;
@@ -37,19 +37,22 @@
   @Argument(index = 0, required = false, metaVar = "NAME", usage = "used to match loggers")
   private String name;
 
-  @SuppressWarnings("unchecked")
   @Override
   protected void run() {
     enableGracefulStop();
     Map<String, String> logs = new TreeMap<>();
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
-      Logger log = logger.nextElement();
-      if (name == null || log.getName().contains(name)) {
-        logs.put(log.getName(), log.getEffectiveLevel().toString());
+    for (Logger logger : getCurrentLoggers()) {
+      if (name == null || logger.getName().contains(name)) {
+        logs.put(logger.getName(), logger.getEffectiveLevel().toString());
       }
     }
     for (Map.Entry<String, String> e : logs.entrySet()) {
       stdout.println(e.getKey() + ": " + e.getValue());
     }
   }
+
+  @SuppressWarnings({"unchecked", "JdkObsolete"})
+  private static Iterable<Logger> getCurrentLoggers() {
+    return Collections.list(LogManager.getCurrentLoggers());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 92666f3..3f2e2ad 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -110,7 +110,7 @@
         msg.append(currentUser.getAccountId());
         msg.append("): ");
         msg.append(badStream.getCause().getMessage());
-        logger.atInfo().log(msg.toString());
+        logger.atInfo().log("%s", msg);
         throw new UnloggedFailure(128, "error: " + badStream.getCause().getMessage());
       }
       StringBuilder msg = new StringBuilder();
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index 3faf598..4d16da6 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.sshd.SshCommand;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.util.Enumeration;
+import java.util.Collections;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -58,27 +58,23 @@
   @Argument(index = 1, required = false, metaVar = "NAME", usage = "used to match loggers")
   private String name;
 
-  @SuppressWarnings("unchecked")
   @Override
   protected void run() throws MalformedURLException {
     enableGracefulStop();
     if (level == LevelOption.RESET) {
       reset();
     } else {
-      for (Enumeration<Logger> logger = LogManager.getCurrentLoggers();
-          logger.hasMoreElements(); ) {
-        Logger log = logger.nextElement();
-        if (name == null || log.getName().contains(name)) {
-          log.setLevel(Level.toLevel(level.name()));
+      for (Logger logger : getCurrentLoggers()) {
+        if (name == null || logger.getName().contains(name)) {
+          logger.setLevel(Level.toLevel(level.name()));
         }
       }
     }
   }
 
-  @SuppressWarnings("unchecked")
   private static void reset() throws MalformedURLException {
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
-      logger.nextElement().setLevel(null);
+    for (Logger logger : getCurrentLoggers()) {
+      logger.setLevel(null);
     }
 
     String path = System.getProperty(JAVA_OPTIONS_LOG_CONFIG);
@@ -88,4 +84,9 @@
       PropertyConfigurator.configure(new URL(path));
     }
   }
+
+  @SuppressWarnings({"unchecked", "JdkObsolete"})
+  private static Iterable<Logger> getCurrentLoggers() {
+    return Collections.list(LogManager.getCurrentLoggers());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index 35cb3ba..244fdbe 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -84,8 +84,7 @@
 
     for (ChangeResource r : changes.values()) {
       SetTopicOp op = topicOpFactory.create(topic);
-      try (BatchUpdate u =
-          updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
+      try (BatchUpdate u = updateFactory.create(r.getChange().getProject(), user, TimeUtil.now())) {
         u.addOp(r.getId(), op);
         u.execute();
       }
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 02956f7..5b89228 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -43,9 +43,10 @@
 import com.google.gerrit.sshd.SshDaemon;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
-import java.util.Date;
 import java.util.Map;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -114,13 +115,16 @@
   protected void run() throws Failure {
     enableGracefulStop();
     nw = columns - 50;
-    Date now = new Date();
+    Instant now = Instant.now();
+    DateTimeFormatter fmt =
+        DateTimeFormatter.ofPattern("HH:mm:ss   zzz").withZone(ZoneId.of("UTC"));
     stdout.format(
         "%-25s %-20s      now  %16s\n",
         "Gerrit Code Review",
         Version.getVersion() != null ? Version.getVersion() : "",
-        new SimpleDateFormat("HH:mm:ss   zzz").format(now));
-    stdout.format("%-25s %-20s   uptime %16s\n", "", "", uptime(now.getTime() - serverStarted));
+        fmt.format(now));
+    stdout.format(
+        "%-25s %-20s   uptime %16s\n", "", "", uptime(now.toEpochMilli() - serverStarted));
     stdout.print('\n');
 
     try {
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index 7eeb770..5efeb42 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -34,8 +34,9 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
-import java.text.SimpleDateFormat;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -171,10 +172,13 @@
   }
 
   private static String time(long now, long time) {
+    Instant instant = Instant.ofEpochMilli(time);
     if (now - time < 24 * 60 * 60 * 1000L) {
-      return new SimpleDateFormat("HH:mm:ss").format(new Date(time));
+      return DateTimeFormatter.ofPattern("HH:mm:ss")
+          .withZone(ZoneId.systemDefault())
+          .format(instant);
     }
-    return new SimpleDateFormat("MMM-dd").format(new Date(time));
+    return DateTimeFormatter.ofPattern("MMM-dd").withZone(ZoneId.systemDefault()).format(instant);
   }
 
   private static String age(long age) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 779f2df..4254e5b 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -35,8 +35,9 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.Environment;
@@ -154,7 +155,7 @@
         stdout.print(
             String.format(
                 "%8s %-12s %-12s %-4s %s\n",
-                task.id, start, startTime(task.startTime), "", command));
+                task.id, start, startTime(task.startTime.toInstant()), "", command));
       } else {
         String remoteName =
             task.remoteName != null ? task.remoteName + "/" + task.projectName : task.projectName;
@@ -164,7 +165,7 @@
                 "%8s %-12s %-4s %s\n",
                 task.id,
                 start,
-                startTime(task.startTime),
+                startTime(task.startTime.toInstant()),
                 MoreObjects.firstNonNull(remoteName, "n/a")));
       }
     }
@@ -178,19 +179,23 @@
   }
 
   private static String time(long now, long delay) {
-    Date when = new Date(now + delay);
+    Instant when = Instant.ofEpochMilli(now + delay);
     return format(when, delay);
   }
 
-  private static String startTime(Date when) {
-    return format(when, TimeUtil.nowMs() - when.getTime());
+  private static String startTime(Instant when) {
+    return format(when, TimeUtil.nowMs() - when.toEpochMilli());
   }
 
-  private static String format(Date when, long timeFromNow) {
+  private static String format(Instant when, long timeFromNow) {
     if (timeFromNow < 24 * 60 * 60 * 1000L) {
-      return new SimpleDateFormat("HH:mm:ss.SSS").format(when);
+      return DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
+          .withZone(ZoneId.systemDefault())
+          .format(when);
     }
-    return new SimpleDateFormat("MMM-dd HH:mm").format(when);
+    return DateTimeFormatter.ofPattern("MMM-dd HH:mm")
+        .withZone(ZoneId.systemDefault())
+        .format(when);
   }
 
   private static String format(Task.State state) {
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index a7a1795..861fa00 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -50,7 +50,6 @@
         "//lib/guice",
         "//lib/guice:guice-servlet",
         "//lib/log:impl-log4j",
-        "//lib/log:log4j",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index ab3348b..49a8d71 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -40,7 +40,7 @@
       return state;
     }
     return newState(
-        Account.builder(accountId, TimeUtil.nowTs())
+        Account.builder(accountId, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build());
   }
diff --git a/java/com/google/gerrit/testing/FloggerInitializer.java b/java/com/google/gerrit/testing/FloggerInitializer.java
index 1972107..7793de1 100644
--- a/java/com/google/gerrit/testing/FloggerInitializer.java
+++ b/java/com/google/gerrit/testing/FloggerInitializer.java
@@ -25,7 +25,7 @@
   public static void initBackend() {
     System.setProperty(
         FLOGGER_BACKEND_PROPERTY,
-        "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
+        "com.google.common.flogger.backend.system.SimpleBackendFactory#getInstance");
     System.setProperty(FLOGGER_LOGGING_CONTEXT, LoggingContext.class.getName() + "#getInstance");
   }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 362e23c..2051ae3 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.testing;
 
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.inject.Inject;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -111,12 +111,12 @@
   }
 
   @Override
-  public synchronized SortedSet<Project.NameKey> list() {
-    SortedSet<Project.NameKey> names = Sets.newTreeSet();
+  public synchronized NavigableSet<Project.NameKey> list() {
+    NavigableSet<Project.NameKey> names = Sets.newTreeSet();
     for (DfsRepository repo : repos.values()) {
       names.add(Project.nameKey(repo.getDescription().getRepositoryName()));
     }
-    return ImmutableSortedSet.copyOf(names);
+    return Collections.unmodifiableNavigableSet(names);
   }
 
   public synchronized void deleteRepository(Project.NameKey name) {
diff --git a/java/com/google/gerrit/testing/IndexVersions.java b/java/com/google/gerrit/testing/IndexVersions.java
index 3281ffc..3810707 100644
--- a/java/com/google/gerrit/testing/IndexVersions.java
+++ b/java/com/google/gerrit/testing/IndexVersions.java
@@ -27,7 +27,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import org.eclipse.jgit.lib.Config;
 
 public class IndexVersions {
@@ -90,13 +90,13 @@
       value = value.trim();
     }
 
-    SortedMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
+    NavigableMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
     if (!Strings.isNullOrEmpty(value)) {
       if (ALL.equals(value)) {
         return ImmutableList.copyOf(schemas.keySet());
       }
 
-      List<Integer> versions = new ArrayList<>();
+      ImmutableList.Builder<Integer> versions = ImmutableList.builder();
       for (String s : Splitter.on(',').trimResults().split(value)) {
         if (CURRENT.equals(s)) {
           versions.add(schemaDef.getLatest().getVersion());
@@ -115,15 +115,15 @@
           versions.add(version);
         }
       }
-      return ImmutableList.copyOf(versions);
+      return versions.build();
     }
 
-    List<Integer> schemaVersions = new ArrayList<>(2);
+    ImmutableList.Builder<Integer> schemaVersions = ImmutableList.builderWithExpectedSize(2);
     if (schemaDef.getPrevious() != null) {
       schemaVersions.add(schemaDef.getPrevious().getVersion());
     }
     schemaVersions.add(schemaDef.getLatest().getVersion());
-    return ImmutableList.copyOf(schemaVersions);
+    return schemaVersions.build();
   }
 
   public static <V> Map<String, Config> asConfigMap(
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index b795c5b..8bd02b8 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -58,7 +58,7 @@
             changeId,
             userId,
             BranchNameKey.create(project, "master"),
-            TimeUtil.nowTs());
+            TimeUtil.now());
     incrementPatchSet(c);
     return c;
   }
@@ -72,7 +72,7 @@
         .id(id)
         .commitId(ObjectId.fromString(revision))
         .uploader(userId)
-        .createdOn(TimeUtil.nowTs())
+        .createdOn(TimeUtil.now())
         .build();
   }
 
@@ -94,7 +94,7 @@
                         injector.getInstance(AbstractChangeNotes.Args.class), c, shouldExist, null)
                     .load(),
                 user,
-                TimeUtil.nowTs(),
+                TimeUtil.now(),
                 Ordering.natural());
 
     ChangeNotes notes = update.getNotes();
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index 5865a3c..400b559 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -33,6 +33,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 
 /** Test helper for dealing with comments/drafts. */
 public class TestCommentHelper {
@@ -64,7 +65,7 @@
     gApi.changes().id(changeId).current().createDraft(in);
   }
 
-  public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+  public List<CommentInfo> getPublishedComments(String changeId) throws Exception {
     return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
diff --git a/java/com/google/gerrit/testing/TestLoggingActivator.java b/java/com/google/gerrit/testing/TestLoggingActivator.java
index ee1a525..c7e22c9 100644
--- a/java/com/google/gerrit/testing/TestLoggingActivator.java
+++ b/java/com/google/gerrit/testing/TestLoggingActivator.java
@@ -14,15 +14,12 @@
 
 package com.google.gerrit.testing;
 
-import static org.apache.log4j.Logger.getLogger;
-
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import org.apache.log4j.ConsoleAppender;
-import org.apache.log4j.Level;
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.PatternLayout;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
+import java.util.logging.Logger;
 
 public class TestLoggingActivator {
   private static final ImmutableMap<String, Level> LOG_LEVELS =
@@ -30,38 +27,38 @@
           .put("com.google.gerrit", getGerritLogLevel())
 
           // Silence non-critical messages from MINA SSHD.
-          .put("org.apache.mina", Level.WARN)
-          .put("org.apache.sshd.client", Level.WARN)
-          .put("org.apache.sshd.common", Level.WARN)
-          .put("org.apache.sshd.server", Level.WARN)
+          .put("org.apache.mina", Level.WARNING)
+          .put("org.apache.sshd.client", Level.WARNING)
+          .put("org.apache.sshd.common", Level.WARNING)
+          .put("org.apache.sshd.server", Level.WARNING)
           .put("org.apache.sshd.common.keyprovider.FileKeyPairProvider", Level.INFO)
-          .put("com.google.gerrit.sshd.GerritServerSession", Level.WARN)
+          .put("com.google.gerrit.sshd.GerritServerSession", Level.WARNING)
 
           // Silence non-critical messages from mime-util.
-          .put("eu.medsea.mimeutil", Level.WARN)
+          .put("eu.medsea.mimeutil", Level.WARNING)
 
           // Silence non-critical messages from openid4java.
-          .put("org.apache.xml", Level.WARN)
-          .put("org.openid4java", Level.WARN)
-          .put("org.openid4java.consumer.ConsumerManager", Level.FATAL)
-          .put("org.openid4java.discovery.Discovery", Level.ERROR)
-          .put("org.openid4java.server.RealmVerifier", Level.ERROR)
-          .put("org.openid4java.message.AuthSuccess", Level.ERROR)
+          .put("org.apache.xml", Level.WARNING)
+          .put("org.openid4java", Level.WARNING)
+          .put("org.openid4java.consumer.ConsumerManager", Level.SEVERE)
+          .put("org.openid4java.discovery.Discovery", Level.SEVERE)
+          .put("org.openid4java.server.RealmVerifier", Level.SEVERE)
+          .put("org.openid4java.message.AuthSuccess", Level.SEVERE)
 
           // Silence non-critical messages from apache.http.
-          .put("org.apache.http", Level.WARN)
+          .put("org.apache.http", Level.WARNING)
 
           // Silence non-critical messages from Jetty.
-          .put("org.eclipse.jetty", Level.WARN)
+          .put("org.eclipse.jetty", Level.WARNING)
 
           // Silence non-critical messages from JGit.
-          .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARN)
-          .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARN)
-          .put("org.eclipse.jgit.internal.transport.sshd", Level.WARN)
-          .put("org.eclipse.jgit.util.FileUtils", Level.WARN)
-          .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARN)
-          .put("org.eclipse.jgit.util.FS", Level.WARN)
-          .put("org.eclipse.jgit.util.SystemReader", Level.WARN)
+          .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARNING)
+          .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARNING)
+          .put("org.eclipse.jgit.internal.transport.sshd", Level.WARNING)
+          .put("org.eclipse.jgit.util.FileUtils", Level.WARNING)
+          .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARNING)
+          .put("org.eclipse.jgit.util.FS", Level.WARNING)
+          .put("org.eclipse.jgit.util.SystemReader", Level.WARNING)
           .build();
 
   private static Level getGerritLogLevel() {
@@ -69,27 +66,44 @@
     if (value.isEmpty()) {
       value = Strings.nullToEmpty(System.getProperty("gerrit.logLevel"));
     }
-    return Level.toLevel(value, Level.INFO);
+
+    try {
+      return Level.parse(value);
+    } catch (IllegalArgumentException e) {
+      // for backwards compatibility handle log4j log levels
+      if (value.equalsIgnoreCase("FATAL") || value.equalsIgnoreCase("ERROR")) {
+        return Level.SEVERE;
+      }
+      if (value.equalsIgnoreCase("WARN")) {
+        return Level.WARNING;
+      }
+      if (value.equalsIgnoreCase("DEBUG")) {
+        return Level.FINE;
+      }
+      if (value.equalsIgnoreCase("TRACE")) {
+        return Level.FINEST;
+      }
+
+      return Level.INFO;
+    }
   }
 
   public static void configureLogging() {
-    LogManager.resetConfiguration();
+    LogManager.getLogManager().reset();
     FloggerInitializer.initBackend();
 
-    PatternLayout layout = new PatternLayout();
-    layout.setConversionPattern("%-5p %c %x: %m%n");
+    ConsoleHandler dst = new ConsoleHandler();
+    dst.setLevel(Level.FINEST);
 
-    ConsoleAppender dst = new ConsoleAppender();
-    dst.setLayout(layout);
-    dst.setTarget("System.err");
-    dst.setThreshold(Level.DEBUG);
-    dst.activateOptions();
+    Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).addHandler(dst);
 
-    Logger root = LogManager.getRootLogger();
-    root.removeAllAppenders();
-    root.addAppender(dst);
-
-    LOG_LEVELS.entrySet().stream().forEach(e -> getLogger(e.getKey()).setLevel(e.getValue()));
+    LOG_LEVELS.entrySet().stream()
+        .forEach(
+            e -> {
+              Logger logger = Logger.getLogger(e.getKey());
+              logger.setLevel(e.getValue());
+              logger.addHandler(dst);
+            });
   }
 
   private TestLoggingActivator() {}
diff --git a/java/com/google/gerrit/util/http/BUILD b/java/com/google/gerrit/util/http/BUILD
index fbd1379..afb4e25 100644
--- a/java/com/google/gerrit/util/http/BUILD
+++ b/java/com/google/gerrit/util/http/BUILD
@@ -4,5 +4,8 @@
     name = "http",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api"],
+    deps = [
+        "//lib:guava",
+        "//lib:servlet-api",
+    ],
 )
diff --git a/java/com/google/gerrit/util/http/RequestUtil.java b/java/com/google/gerrit/util/http/RequestUtil.java
index f64ce5a..51ffe0c 100644
--- a/java/com/google/gerrit/util/http/RequestUtil.java
+++ b/java/com/google/gerrit/util/http/RequestUtil.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.util.http;
 
+import com.google.common.base.Splitter;
+import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 
 /** Utilities for manipulating HTTP request objects. */
 public class RequestUtil {
+  private static final Splitter SPLITTER = Splitter.on("/");
+
   /** HTTP request attribute for storing the Throwable that caused an error condition. */
   private static final String ATTRIBUTE_ERROR_TRACE =
       RequestUtil.class.getName() + "/ErrorTraceThrowable";
@@ -80,11 +84,11 @@
       encodedPathInfo = encodedPathInfo.substring(2);
     }
 
-    String[] parts = encodedPathInfo.split("/");
-    StringBuilder result = new StringBuilder(parts.length);
-    for (int i = 0; i < parts.length; i = i + 2) {
+    List<String> parts = SPLITTER.splitToList(encodedPathInfo);
+    StringBuilder result = new StringBuilder(parts.size());
+    for (int i = 0; i < parts.size(); i = i + 2) {
       result.append("/");
-      result.append(parts[i]);
+      result.append(parts.get(i));
     }
     return result.toString();
   }
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
index 7758be6..2ea2a55 100644
--- a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -126,7 +126,7 @@
 
   private static String getMessageId(FakeEmailSender sender) {
     return ((StringEmailHeader)
-            (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
+            Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID"))
         .getString();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 7d04558..877ccd5 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
+import java.util.Date;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
@@ -332,10 +333,13 @@
     assertThat(repo.exactRef(ref.getName())).isNull();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo) throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
index ecfe3f5..9d689ba 100644
--- a/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
+++ b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.server.util.time.TimeUtil;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.concurrent.TimeUnit;
 import org.junit.Test;
@@ -52,6 +51,6 @@
   @Test
   @UseClockStep(startAtEpoch = true)
   public void useClockStepWithStartAtEpoch() {
-    assertThat(TimeUtil.nowTs()).isEqualTo(Timestamp.from(Instant.EPOCH));
+    assertThat(TimeUtil.now()).isEqualTo(Instant.EPOCH);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 915e759..78a0eeb 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -158,6 +158,7 @@
 import java.security.KeyPair;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -499,7 +500,7 @@
       assertThat(ref).isNotNull();
       RevCommit c = rw.parseCommit(ref.getObjectId());
       long timestampDiffMs =
-          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().getTime());
+          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().toEpochMilli());
       assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
 
       // Check the 'account.config' file.
@@ -980,7 +981,8 @@
     assertThat(detail.email).isEqualTo(email);
     assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
     assertThat(detail.status).isEqualTo(status);
-    assertThat(detail.registeredOn).isEqualTo(getAccount(foo.id()).registeredOn());
+    assertThat(detail.registeredOn.getTime())
+        .isEqualTo(getAccount(foo.id()).registeredOn().toEpochMilli());
     assertThat(detail.inactive).isNull();
     assertThat(detail._moreAccounts).isNull();
   }
@@ -2464,6 +2466,9 @@
   }
 
   @Test
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
@@ -2477,7 +2482,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(commit.getTree());
       cb.setCommitter(ident);
@@ -2497,7 +2502,7 @@
     // stale.
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers,
               repo,
               externalIdFactory,
@@ -2511,7 +2516,7 @@
       assertStaleAccountAndReindex(accountId);
 
       extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers,
               repo,
               externalIdFactory,
@@ -2523,7 +2528,7 @@
       assertStaleAccountAndReindex(accountId);
 
       extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers,
               repo,
               externalIdFactory,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c7aac96..c5f0d23 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -31,6 +31,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.client.ChangeStatus.MERGED;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -69,7 +70,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MoreCollectors;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
@@ -85,6 +85,7 @@
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -99,16 +100,10 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
-import com.google.gerrit.entities.SubmitRequirementExpression;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult;
-import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -150,12 +145,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.SubmitRecordInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementInput;
-import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -174,13 +164,15 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.IntraLineDiff;
@@ -189,7 +181,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeOperatorFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -203,9 +194,11 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -214,6 +207,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 org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -225,7 +219,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
-import org.junit.Ignore;
 import org.junit.Test;
 
 @NoHttpd
@@ -240,6 +233,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Inject
   @Named("diff_intraline")
@@ -1026,6 +1020,31 @@
   }
 
   @Test
+  public void rebaseWithValidationOptions() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      // Rebase the second change
+      gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void deleteNewChangeAsAdmin() throws Exception {
     deleteChangeAsUser(admin, admin);
   }
@@ -1925,7 +1944,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     addReviewer.call(r.getChangeId(), user.email());
 
@@ -2036,7 +2055,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
     String email = "abcd@example.com";
@@ -2085,7 +2104,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "kobe" with one user: lee
     String testUserFullname = "kobebryant";
@@ -2186,7 +2205,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
@@ -2830,6 +2849,53 @@
   }
 
   @Test
+  public void labelPermissionsChange_doesNotAffectCurrentVotes() throws Exception {
+    String heads = "refs/heads/*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Approve the change as user
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+    assertThat(
+            gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2));
+
+    // Remove permissions for CODE_REVIEW. The user still has [-1,+1], inherited from All-Projects.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS))
+        .update();
+
+    // No permissions to vote +2
+    assertThrows(AuthException.class, () -> approve(changeId));
+
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .map(vote -> vote.value))
+        .containsExactly(2);
+
+    // The change is still submittable
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).current().submit();
+    assertThat(info(changeId).status).isEqualTo(MERGED);
+
+    // The +2 vote out of permissions range is still present.
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2, admin.id(), 0));
+  }
+
+  @Test
   public void createEmptyChange() throws Exception {
     ChangeInput in = new ChangeInput();
     in.branch = Constants.MASTER;
@@ -3091,17 +3157,14 @@
   public void submitStaleChange() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    disableChangeIndexWrites();
-    try {
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
       r = amendChange(r.getChangeId());
-    } finally {
-      enableChangeIndexWrites();
     }
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
 
     gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3127,7 +3190,7 @@
         .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3373,7 +3436,7 @@
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.updated, serverIdent.get());
+              getAccount(admin.id()).id(), c.updated.toInstant(), serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -3383,7 +3446,7 @@
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.created, serverIdent.get());
+              getAccount(admin.id()).id(), c.created.toInstant(), serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -3495,6 +3558,55 @@
     r2.assertOkStatus();
   }
 
+  private void assertLabelDescription(ChangeInfo changeInfo, String labelName, String description) {
+    assertThat(changeInfo.labels.get(labelName).description).isEqualTo(description);
+  }
+
+  @Test
+  public void checkLabelVotesForUnsubmittedChange() throws Exception {
+    List<ListChangesOption> options =
+        EnumSet.complementOf(
+                EnumSet.of(
+                    ListChangesOption.CHECK,
+                    ListChangesOption.SKIP_DIFFSTAT,
+                    ListChangesOption.DETAILED_LABELS))
+            .stream()
+            .collect(Collectors.toList());
+    PushOneCommit.Result r = createChange();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).all).isNull();
+
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +1);
+    change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    List<ApprovalInfo> codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    assertThat(codeReviewApprovals).hasSize(1);
+    ApprovalInfo codeReviewApproval = codeReviewApprovals.get(0);
+    // permittedVotingRange is not served if DETAILED_LABELS is not requested.
+    assertThat(codeReviewApproval.permittedVotingRange).isNull();
+    assertThat(codeReviewApproval.value).isEqualTo(1);
+    assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+
+    // Add another +1 vote as user
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +1);
+    change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).all).hasSize(2);
+    // All available label votes and their meanings are also served if DETAILED_LABELS is not
+    // requested.
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).values).isNotNull();
+    codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    assertThat(codeReviewApprovals.stream().map(a -> a.permittedVotingRange).collect(toList()))
+        .containsExactly(null, null);
+    assertThat(codeReviewApprovals.stream().map(a -> a.value).collect(toList()))
+        .containsExactly(1, 1);
+    assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
+        .containsExactly(admin.username(), user.username());
+  }
+
   @Test
   public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -3524,6 +3636,7 @@
         .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, -2, -1, 0, 1, 2);
     assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
+    assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
 
     // add an approval on the new label
     gApi.changes()
@@ -3560,13 +3673,63 @@
   }
 
   @Test
+  public void checkLabelVotesForMergedChange() throws Exception {
+    List<ListChangesOption> options =
+        EnumSet.complementOf(
+                EnumSet.of(
+                    ListChangesOption.CHECK,
+                    ListChangesOption.SKIP_DIFFSTAT,
+                    ListChangesOption.DETAILED_LABELS))
+            .stream()
+            .collect(Collectors.toList());
+    PushOneCommit.Result r = createChange();
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +2);
+
+    // Add another label for 'Verified'
+    LabelType verified = TestLabels.verified();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
+
+    // Submit the change
+    voteLabel(r.getChangeId(), TestLabels.verified().getName(), 1);
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Make sure label votes are available if DETAILED_LABELS is not requested.
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, TestLabels.verified().getName());
+    List<ApprovalInfo> codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    List<ApprovalInfo> verifiedApprovals = change.labels.get(TestLabels.verified().getName()).all;
+
+    assertThat(codeReviewApprovals).hasSize(1);
+    assertThat(codeReviewApprovals.get(0).value).isEqualTo(2);
+    assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
+
+    assertThat(verifiedApprovals).hasSize(1);
+    assertThat(verifiedApprovals.get(0).value).isEqualTo(1);
+    assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
+  }
+
+  @Test
   public void checkLabelsForMergedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
@@ -3593,9 +3756,10 @@
         .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
     assertPermitted(change, LabelId.VERIFIED, 0, 1);
+    assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
 
-    // ignore the new label by Prolog submit rule and assert that the label is
-    // no longer returned
+    // Ignore the new label by Prolog submit rule. Permitted ranges are still going to be
+    // returned for the label.
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
     PushOneCommit push2 =
@@ -3609,11 +3773,10 @@
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.VERIFIED, 0, 1);
 
-    // add an approval on the new label and assert that the label is now
-    // returned although it is ignored by the Prolog submit rule and hence not
-    // included in the submit records
+    // add an approval on the new label. The label can still be voted +1 although it is ignored
+    // in Prolog. 0 is not permitted because votes cannot be decreased.
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
@@ -3622,7 +3785,7 @@
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.VERIFIED, 1);
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -3730,7 +3893,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet())
         .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
@@ -3747,7 +3910,7 @@
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
@@ -3764,11 +3927,11 @@
     result.assertOkStatus();
 
     ChangeInfo firstChange = gApi.changes().id(first.getChangeId()).get();
-    assertThat(firstChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(firstChange.status).isEqualTo(MERGED);
     assertThat(firstChange.submissionId).isNotNull();
 
     ChangeInfo secondChange = gApi.changes().id(second.getChangeId()).get();
-    assertThat(secondChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(secondChange.status).isEqualTo(MERGED);
     assertThat(secondChange.submissionId).isNotNull();
 
     assertThat(secondChange.submissionId).isEqualTo(firstChange.submissionId);
@@ -3924,7 +4087,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs All-Comments-Resolved");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'All-Comments-Resolved' is unsatisfied");
   }
 
   @Test
@@ -4050,938 +4215,6 @@
   }
 
   @Test
-  public void submitRecords() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestSubmitRule testSubmitRule = new TestSubmitRule();
-    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
-      String changeId = r.getChangeId();
-
-      ChangeInfo change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRecords).hasSize(2);
-      // Check the default submit record for the code-review label
-      SubmitRecordInfo codeReviewRecord = Iterables.get(change.submitRecords, 0);
-      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
-      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.NOT_READY);
-      assertThat(codeReviewRecord.labels).hasSize(1);
-      SubmitRecordInfo.Label label = Iterables.getOnlyElement(codeReviewRecord.labels);
-      assertThat(label.label).isEqualTo("Code-Review");
-      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.NEED);
-      assertThat(label.appliedBy).isNull();
-      // Check the custom test record created by the TestSubmitRule
-      SubmitRecordInfo testRecord = Iterables.get(change.submitRecords, 1);
-      assertThat(testRecord.ruleName).isEqualTo("gerrit~TestSubmitRule");
-      assertThat(testRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
-      assertThat(testRecord.requirements)
-          .containsExactly(new LegacySubmitRequirementInfo("OK", "fallback text", "type"));
-      assertThat(testRecord.labels).hasSize(1);
-      SubmitRecordInfo.Label testLabel = Iterables.getOnlyElement(testRecord.labels);
-      assertThat(testLabel.label).isEqualTo("label");
-      assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
-      assertThat(testLabel.appliedBy).isNull();
-
-      voteLabel(changeId, "code-review", 2);
-      // Code review record is satisfied after voting +2
-      change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRecords).hasSize(2);
-      codeReviewRecord = Iterables.get(change.submitRecords, 0);
-      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
-      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
-      assertThat(codeReviewRecord.labels).hasSize(1);
-      label = Iterables.getOnlyElement(codeReviewRecord.labels);
-      assertThat(label.label).isEqualTo("Code-Review");
-      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
-      assertThat(label.appliedBy._accountId).isEqualTo(admin.id().get());
-    }
-  }
-
-  @Test
-  public void checkSubmitRequirement_satisfied() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
-  }
-
-  @Test
-  public void checkSubmitRequirement_notApplicable() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review",
-            /* applicableIf= */ "branch:non-existent",
-            /* submittableIf= */ "label:Code-Review=+2",
-            /* overrideIf= */ null);
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
-  }
-
-  @Test
-  public void checkSubmitRequirement_overridden() throws Exception {
-    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("Override-Label")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review",
-            /* applicableIf= */ null,
-            /* submittableIf= */ "label:Code-Review=+2",
-            /* overrideIf= */ "label:Override-Label=+1");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
-
-    voteLabel(changeId, "Override-Label", 1);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
-  }
-
-  @Test
-  public void checkSubmitRequirement_error() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMax() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:code-review=MAX"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
-    configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
-        .update();
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("my-label")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    // The second requirement is coming from the legacy code-review label function
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Voting with a max vote as the uploader will not satisfy the submit requirement.
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Voting as a non-uploader will satisfy the submit requirement.
-    requestScopeOperations.setApiUser(user.id());
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("-label:code-review=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Requirement is satisfied because there are no votes
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement (coming from the label function definition) is not satisfied. We return
-    // both legacy and non-legacy requirements in this case since their statuses are not identical.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    voteLabel(changeId, "code-review", -1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Requirement is still satisfied because -1 is not the max negative value
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    voteLabel(changeId, "code-review", -2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    // Requirement is now unsatisfied because -2 is the max negative value
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
-    configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
-        .update();
-
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("my-label")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create(
-                    "label:my-label=MAX,user=non_uploader -label:my-label=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create the change as admin
-    requestScopeOperations.setApiUser(admin.id());
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
-    voteLabel(changeId, "my-label", -1);
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    // The other requirement is coming from the code-review label function
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // user (i.e. non_uploader) votes 1. Requirement is still blocking because of -1 of uploader.
-    requestScopeOperations.setApiUser(user.id());
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Admin (a.k.a uploader) removes -1. Now requirement is fulfilled.
-    requestScopeOperations.setApiUser(admin.id());
-    voteLabel(changeId, "my-label", 0);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsAny() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:code-review=ANY"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
-      throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
-      throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
-    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "build-cop-override", 1);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.OVERRIDDEN, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @Ignore("Test is flaky")
-  public void submitRequirement_overriddenInChildProject() throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(true)
-            .build());
-
-    // Override submit requirement in child project (requires code-review=+2 instead of +1)
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_inheritedFromParentProject() throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
-      throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Override submit requirement in child project (requires code-review=+2 instead of +1).
-    // Will have no effect since parent does not allow override.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // +1 was enough to fulfill the requirement: override in child project was ignored
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_storedForClosedChanges() throws Exception {
-    for (SubmitType submitType : SubmitType.values()) {
-      Project.NameKey project = createProjectForPush(submitType);
-      TestRepository<InMemoryRepository> repo = cloneProject(project);
-      configSubmitRequirement(
-          project,
-          SubmitRequirement.builder()
-              .setName("code-review")
-              .setSubmittabilityExpression(
-                  SubmitRequirementExpression.create("label:code-review=+2"))
-              .setAllowOverrideInChildProjects(false)
-              .build());
-
-      PushOneCommit.Result r =
-          createChange(repo, "master", "Add a file", "foo", "content", "topic");
-      String changeId = r.getChangeId();
-
-      voteLabel(changeId, "code-review", 2);
-
-      ChangeInfo change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRequirements).hasSize(1);
-      assertSubmitRequirementStatus(
-          change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-
-      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-      revision.review(ReviewInput.approve());
-      revision.submit();
-
-      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
-
-      SubmitRequirementResult result =
-          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
-      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
-      assertThat(result.submittabilityExpressionResult().status())
-          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
-      assertThat(result.submittabilityExpressionResult().expression().expressionString())
-          .isEqualTo("label:code-review=+2");
-    }
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-
-    gApi.changes().id(changeId).current().submit();
-
-    // Add new submit requirement
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // The new "verified" submit requirement is not returned, since this change is closed
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void
-      submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
-          throws Exception {
-    // Configure a legacy submit requirement: label with a max with block function
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Configure a submit requirement with the same name.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("build-cop-override")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create(
-                    "label:build-cop-override=MAX -label:build-cop-override=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create a change. Vote to fulfill all requirements.
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    voteLabel(changeId, "build-cop-override", 1);
-    voteLabel(changeId, "Code-Review", 2);
-
-    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
-    // Only non-legacy bco is returned.
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-
-    // Merge the change. Submit requirements are still the same.
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void
-      submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
-          throws Exception {
-    // Configure a legacy submit requirement: label with a max with block function
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Configure a submit requirement with the same name.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("build-cop-override")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:build-cop-override=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create a change
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    voteLabel(changeId, "build-cop-override", 1);
-    voteLabel(changeId, "Code-Review", 2);
-
-    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
-    // Two instances of bco will be returned since their status is not matching.
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(3);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ true,
-        // MAX_WITH_BLOCK function was translated to a submittability expression.
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.UNSATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MIN");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // 1. Project has two legacy requirements: Code-Review and bco. Both unsatisfied.
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    // 2. Vote +1 on bco. bco becomes satisfied
-    voteLabel(changeId, "build-cop-override", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-
-    // 3. Vote +1 on Code-Review. Code-Review becomes satisfied
-    voteLabel(changeId, "Code-Review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-
-    // 4. Merge the change. Submit requirements status is presented from NoteDb.
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    // Legacy submit records are returned as submit requirements.
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    voteLabel(changeId, "code-review", 2);
-
-    // Query the change. ChangeInfo is back-filled from the change index.
-    List<ChangeInfo> changeInfos =
-        gApi.changes()
-            .query()
-            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
-            .get();
-    assertThat(changeInfos).hasSize(1);
-    assertSubmitRequirementStatus(
-        changeInfos.get(0).submitRequirements,
-        "code-review",
-        Status.SATISFIED,
-        /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    voteLabel(changeId, "code-review", 2);
-    gApi.changes().id(changeId).current().submit();
-
-    // Query the change. ChangeInfo is back-filled from the change index.
-    List<ChangeInfo> changeInfos =
-        gApi.changes()
-            .query()
-            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
-            .get();
-    assertThat(changeInfos).hasSize(1);
-    assertSubmitRequirementStatus(
-        changeInfos.get(0).submitRequirements,
-        "code-review",
-        Status.SATISFIED,
-        /* isLegacy= */ false);
-  }
-
-  @Test
-  public void submitRequirements_notServedIfExperimentNotEnabled() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "code-review", -1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "code-review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-  }
-
-  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -5233,7 +4466,7 @@
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
       batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
       batchUpdate.execute();
     }
@@ -5466,7 +4699,7 @@
     PushOneCommit.Result change = createChange();
     int number = gApi.changes().id(change.getChangeId()).get()._number;
 
-    try (AutoCloseable ignored = disableChangeIndex()) {
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
       assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
           .isEqualTo(change.getChangeId());
     }
@@ -5523,99 +4756,14 @@
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
 
-  private void assertSubmitRequirementStatus(
-      Collection<SubmitRequirementResultInfo> results,
-      String requirementName,
-      SubmitRequirementResultInfo.Status status,
-      boolean isLegacy,
-      String submittabilityCondition) {
-    for (SubmitRequirementResultInfo result : results) {
-      if (result.name.equals(requirementName)
-          && result.status == status
-          && result.isLegacy == isLegacy
-          && result.submittabilityExpressionResult.expression.equals(submittabilityCondition)) {
-        return;
-      }
-    }
-    throw new AssertionError(
-        String.format(
-            "Could not find submit requirement %s with status %s (results = %s)",
-            requirementName,
-            status,
-            results.stream()
-                .map(r -> String.format("%s=%s", r.name, r.status))
-                .collect(toImmutableList())));
-  }
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
 
-  private void assertSubmitRequirementStatus(
-      Collection<SubmitRequirementResultInfo> results,
-      String requirementName,
-      SubmitRequirementResultInfo.Status status,
-      boolean isLegacy) {
-    for (SubmitRequirementResultInfo result : results) {
-      if (result.name.equals(requirementName)
-          && result.status == status
-          && result.isLegacy == isLegacy) {
-        return;
-      }
-    }
-    throw new AssertionError(
-        String.format(
-            "Could not find submit requirement %s with status %s (results = %s)",
-            requirementName,
-            status,
-            results.stream()
-                .map(r -> String.format("%s=%s", r.name, r.status))
-                .collect(toImmutableList())));
-  }
-
-  private Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
-    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
-        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
-        .update();
-    return project;
-  }
-
-  /** Returns a hard-coded submit record containing all fields. */
-  private static class TestSubmitRule implements SubmitRule {
     @Override
-    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      SubmitRecord record = new SubmitRecord();
-      record.ruleName = "testSubmitRule";
-      record.status = SubmitRecord.Status.OK;
-      SubmitRecord.Label label = new SubmitRecord.Label();
-      label.label = "label";
-      label.status = SubmitRecord.Label.Status.OK;
-      record.labels = Arrays.asList(label);
-      record.requirements =
-          Arrays.asList(
-              LegacySubmitRequirement.builder()
-                  .setType("type")
-                  .setFallbackText("fallback text")
-                  .build());
-      return Optional.of(record);
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
     }
   }
-
-  private static SubmitRequirementInput createSubmitRequirementInput(
-      String name, String submittabilityExpression) {
-    SubmitRequirementInput input = new SubmitRequirementInput();
-    input.name = name;
-    input.submittabilityExpression = submittabilityExpression;
-    return input;
-  }
-
-  private static SubmitRequirementInput createSubmitRequirementInput(
-      String name, String applicableIf, String submittableIf, String overrideIf) {
-    SubmitRequirementInput input = new SubmitRequirementInput();
-    input.name = name;
-    input.applicabilityExpression = applicableIf;
-    input.submittabilityExpression = submittableIf;
-    input.overrideExpression = overrideIf;
-    return input;
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
deleted file mode 100644
index c2f7771..0000000
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
+++ /dev/null
@@ -1,257 +0,0 @@
-// 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.api.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-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.RefNames;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.server.approval.RecursiveApprovalCopier;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.junit.Test;
-
-public class CopyApprovalsIT extends AbstractDaemonTest {
-  @Inject private ProjectOperations projectOperations;
-  @Inject private RecursiveApprovalCopier recursiveApprovalCopier;
-
-  @Test
-  public void multipleProjects() throws Exception {
-    Project.NameKey secondProject = projectOperations.newProject().name("secondProject").create();
-    TestRepository<InMemoryRepository> secondRepo = cloneProject(secondProject, admin);
-
-    PushOneCommit.Result change1 = createChange();
-    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
-    PushOneCommit.Result change2 = createChange(secondRepo);
-    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.dislike());
-
-    // these amends are reworks so votes will not be copied.
-    amendChange(change1.getChangeId());
-    amendChange(change1.getChangeId());
-    amendChange(change1.getChangeId());
-
-    amendChange(change2.getChangeId(), "refs/for/master", admin, secondRepo);
-    amendChange(change2.getChangeId(), "refs/for/master", admin, secondRepo);
-    amendChange(change2.getChangeId(), "refs/for/master", admin, secondRepo);
-
-    // votes don't exist on the new patch-set.
-    assertThat(gApi.changes().id(change1.getChangeId()).current().votes()).isEmpty();
-    assertThat(gApi.changes().id(change2.getChangeId()).current().votes()).isEmpty();
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    recursiveApprovalCopier.persistStandalone();
-
-    ApprovalInfo vote1 =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change1.getChangeId()).current().votes().values());
-    assertThat(vote1.value).isEqualTo(1);
-    assertThat(vote1._accountId).isEqualTo(admin.id().get());
-
-    ApprovalInfo vote2 =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change2.getChangeId()).current().votes().values());
-    assertThat(vote2.value).isEqualTo(-1);
-    assertThat(vote2._accountId).isEqualTo(admin.id().get());
-  }
-
-  @Test
-  public void corruptChangeInOneProject_OtherProjectsProcessed() throws Exception {
-    Project.NameKey corruptProject = projectOperations.newProject().name("corruptProject").create();
-    TestRepository<InMemoryRepository> corruptRepo = cloneProject(corruptProject, admin);
-
-    PushOneCommit.Result change1 = createChange();
-    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
-    PushOneCommit.Result change2 = createChange(corruptRepo);
-    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.dislike());
-
-    // these amends are reworks so votes will not be copied.
-    amendChange(change1.getChangeId());
-    amendChange(change2.getChangeId(), "refs/for/master", admin, corruptRepo);
-
-    // votes don't exist on the new patch-set.
-    assertThat(gApi.changes().id(change1.getChangeId()).current().votes()).isEmpty();
-    assertThat(gApi.changes().id(change2.getChangeId()).current().votes()).isEmpty();
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    // make the meta-ref of change2 corrupt by updating it to the commit of the current patch-set
-    ObjectId correctMetaRefObjectId;
-    String metaRef = RefNames.changeMetaRef(change2.getChange().getId());
-    try (TestRepository<InMemoryRepository> serverSideCorruptRepo =
-        new TestRepository<>((InMemoryRepository) repoManager.openRepository(corruptProject))) {
-      RefUpdate ru = forceUpdate(serverSideCorruptRepo, metaRef, change2.getPatchSet().commitId());
-      correctMetaRefObjectId = ru.getOldObjectId();
-
-      try {
-        recursiveApprovalCopier.persistStandalone();
-        fail("Expected exception when a project contains corrupt change");
-      } catch (Exception e) {
-        assertThat(e.getMessage()).contains("check the logs");
-      } finally {
-        // fix the meta-ref by setting it back to its correct objectId
-        forceUpdate(serverSideCorruptRepo, metaRef, correctMetaRefObjectId);
-      }
-    }
-
-    ApprovalInfo vote1 =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change1.getChangeId()).current().votes().values());
-    assertThat(vote1.value).isEqualTo(1);
-    assertThat(vote1._accountId).isEqualTo(admin.id().get());
-  }
-
-  private RefUpdate forceUpdate(
-      TestRepository<InMemoryRepository> repo, String ref, ObjectId newObjectId)
-      throws IOException {
-    RefUpdate ru = repo.getRepository().updateRef(ref);
-    ru.setNewObjectId(newObjectId);
-    ru.forceUpdate();
-    return ru;
-  }
-
-  @Test
-  public void changeWithPersistedVotesNotHarmed() throws Exception {
-    // change the project config to copy all votes
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result change = createChange();
-    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.recommend());
-    amendChange(change.getChangeId());
-
-    // vote exists on new patch-set.
-    ApprovalInfo vote =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change.getChangeId()).current().votes().values());
-
-    ChangeNotes notes = notesFactory.createChecked(project, change.getChange().getId()).load();
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> multimap1 = notes.getApprovalsWithCopied();
-
-    recursiveApprovalCopier.persist(change.getChange().change());
-
-    ChangeNotes notes2 = notesFactory.createChecked(project, change.getChange().getId()).load();
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> multimap2 =
-        notes2.getApprovalsWithCopied();
-    assertThat(multimap1).containsExactlyEntriesIn(multimap2);
-
-    // the vote hasn't changed.
-    assertThat(
-            Iterables.getOnlyElement(
-                gApi.changes().id(change.getChangeId()).current().votes().values()))
-        .isEqualTo(vote);
-  }
-
-  @Test
-  public void multipleChanges() throws Exception {
-    List<Result> changes = new ArrayList<>();
-
-    // The test also passes with 1000, but we replaced this number to 5 to speed up the test.
-    for (int i = 0; i < 5; i++) {
-      PushOneCommit.Result change = createChange();
-      gApi.changes().id(change.getChangeId()).current().review(ReviewInput.recommend());
-
-      // this amend is a rework so votes will not be copied.
-      amendChange(change.getChangeId());
-
-      changes.add(change);
-
-      // votes don't exist on the new patch-set for all changes.
-      assertThat(gApi.changes().id(change.getChangeId()).current().votes()).isEmpty();
-    }
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    recursiveApprovalCopier.persist(project, null);
-
-    for (PushOneCommit.Result change : changes) {
-      ApprovalInfo vote1 =
-          Iterables.getOnlyElement(
-              gApi.changes().id(change.getChangeId()).current().votes().values());
-      assertThat(vote1.value).isEqualTo(1);
-      assertThat(vote1._accountId).isEqualTo(admin.id().get());
-    }
-  }
-
-  @Test
-  public void oneCorruptChange_otherChangesProcessed() throws Exception {
-    PushOneCommit.Result good = createChange();
-    gApi.changes().id(good.getChangeId()).current().review(ReviewInput.recommend());
-    // this amend is a rework so votes will not be copied.
-    amendChange(good.getChangeId());
-
-    PushOneCommit.Result corrupt = createChange();
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    // make the meta-ref corrupt by updating it to the commit of the current patch-set
-    String metaRef = RefNames.changeMetaRef(corrupt.getChange().getId());
-    try (TestRepository<InMemoryRepository> serverSideTestRepo =
-        new TestRepository<>((InMemoryRepository) repoManager.openRepository(project))) {
-      RefUpdate ru = forceUpdate(serverSideTestRepo, metaRef, corrupt.getPatchSet().commitId());
-      try {
-        recursiveApprovalCopier.persist(project, null);
-      } finally {
-        forceUpdate(serverSideTestRepo, metaRef, ru.getOldObjectId());
-      }
-    }
-
-    ApprovalInfo vote1 =
-        Iterables.getOnlyElement(gApi.changes().id(good.getChangeId()).current().votes().values());
-    assertThat(vote1.value).isEqualTo(1);
-    assertThat(vote1._accountId).isEqualTo(admin.id().get());
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
index aee7f6f..2bde1652 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.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.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
index f4cf96d..3ffbda1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -46,7 +47,11 @@
 
     String oddChangeId = createChange().getChangeId();
     String evenChangeId = createChange().getChangeId();
-    assertThat(getChanges(queryChanges)).hasSize(0);
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> getChanges(queryChanges));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("Unrecognized value: changeNumberEven_myplugin");
 
     try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
       List<?> changes = getChanges(queryChanges);
@@ -57,8 +62,6 @@
       assertThat(outputChangeId).isEqualTo(evenChangeId);
       assertThat(outputChangeId).isNotEqualTo(oddChangeId);
     }
-
-    assertThat(getChanges(queryChanges)).hasSize(0);
   }
 
   protected static class IsOperatorModule extends AbstractModule {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 96bc65d..dcd8f77f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 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.PatchSet;
@@ -83,6 +84,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -117,6 +120,7 @@
           COMMENT_TEXT.length());
 
   @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> captor;
+  @Captor private ArgumentCaptor<CommentValidationContext> captorCtx;
 
   private static final Correspondence<CommentForValidation, CommentForValidation>
       COMMENT_CORRESPONDENCE =
@@ -152,7 +156,7 @@
   @Test
   public void validateCommentsInInput_commentOK() throws Exception {
     PushOneCommit.Result r = createChange();
-    when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture()))
+    when(mockCommentValidator.validateComments(captorCtx.capture(), captor.capture()))
         .thenReturn(ImmutableList.of());
 
     ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
@@ -165,6 +169,7 @@
 
     assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, FILE_COMMENT_FOR_VALIDATION);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+    assertThat(captorCtx.getAllValues()).containsExactly(contextFor(r));
   }
 
   @Test
@@ -631,8 +636,15 @@
     assertThat(r.getChange().approvals().values()).hasSize(1);
 
     // Post without changing the vote.
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+    ObjectId metaId = notes.getMetaId();
+    assertAttentionSet(notes.getAttentionSet(), user.id());
     input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
     gApi.changes().id(r.getChangeId()).current().review(input);
+    notes = notesFactory.create(project, r.getChange().getId());
+    // Change meta ID did not change since the update is No/Op. Attention set is same.
+    assertThat(notes.getMetaId()).isEqualTo(metaId);
+    assertAttentionSet(notes.getAttentionSet(), user.id());
 
     // Second vote replaced the original vote, so still only one vote.
     assertThat(r.getChange().approvals().values()).hasSize(1);
@@ -985,7 +997,9 @@
 
   private static CommentValidationContext contextFor(PushOneCommit.Result result) {
     return CommentValidationContext.create(
-        result.getChange().getId().get(), result.getChange().project().get());
+        result.getChange().getId().get(),
+        result.getChange().project().get(),
+        result.getChange().change().getDest().branch());
   }
 
   private void assertValidatorCalledWith(CommentForValidation... commentsForValidation) {
@@ -1082,4 +1096,10 @@
           .collect(toImmutableSet());
     }
   }
+
+  private static void assertAttentionSet(
+      ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) {
+    assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList()))
+        .containsExactly(accounts);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index 97b7148..267f5a7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -251,7 +251,7 @@
   private void markMergedChangePrivate(Change.Id changeId) throws Exception {
     try (BatchUpdate u =
         batchUpdateFactory.create(
-            project, identifiedUserFactory.create(admin.id()), TimeUtil.nowTs())) {
+            project, identifiedUserFactory.create(admin.id()), TimeUtil.now())) {
       u.addOp(
               changeId,
               new BatchUpdateOp() {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 31381dd..3bfb573 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -371,7 +371,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection s : cfg.getAccessSections()) {
       if (s.getName().startsWith("refs/heads/")
           || s.getName().startsWith("refs/for/")
           || s.getName().equals("refs/*")) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 1f8ea65..407d04e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -80,7 +80,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
   }
 
   @Test
@@ -101,7 +103,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
   }
 
   @Test
@@ -705,6 +709,43 @@
   }
 
   @Test
+  public void revertSubmissionSuppressNotifications() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(secondResult).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertSubmissionSuppressNotificationsWithWip() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.workInProgress = true;
+    revertInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(secondResult).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void revertSubmissionWipNotificationsWithNotifyHandlingAll() throws Exception {
     String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
     approve(changeId1);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 3888679..8dbef88 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -29,42 +29,60 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Comparator.comparing;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
@@ -72,9 +90,11 @@
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
   @Inject private ChangeKindCreator changeKindCreator;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Inject
   @Named("change_kind")
@@ -91,8 +111,8 @@
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
               value(0, "No score"),
-              value(-1, "I would prefer that you didn't submit this"),
-              value(-2, "Do not submit"));
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
       codeReview.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(codeReview.build());
 
@@ -127,12 +147,28 @@
   }
 
   @Test
-  public void stickyOnAnyScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+  public void stickyOnAnyScore_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyOnAnyScore();
+  }
 
+  @Test
+  public void stickyOnAnyScore_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
+    testStickyOnAnyScore();
+  }
+
+  @Test
+  public void stickyOnRework() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:REWORK"));
+
+    // changekind:REWORK should match all kind of changes so that approvals are always copied.
+    // This means setting changekind:REWORK is equivalent to setting is:ANY and we can do the same
+    // assertions for both cases.
+    testStickyOnAnyScore();
+  }
+
+  private void testStickyOnAnyScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -149,55 +185,18 @@
   }
 
   @Test
-  public void stickyWhenCopyConditionIsTrue() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:ANY"));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, 1, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, 1, 0, changeKind);
-    }
+  public void stickyOnMinScore_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMinScore(true));
+    testStickyOnMinScore();
   }
 
   @Test
-  public void stickyEvenWhenUserCantSeeUploaderInGroup() throws Exception {
-    // user can't see admin group
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyCondition("approverin:" + administratorsUUID));
-      u.save();
-    }
-
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-    amendChange(changeId);
-    vote(user, changeId, 1, -1); // Invalidate cache
-    requestScopeOperations.setApiUser(user.id());
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, 1, -1);
+  public void stickyOnMinScore_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:min"));
+    testStickyOnMinScore();
   }
 
-  @Test
-  public void stickyOnMinScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
-      u.save();
-    }
-
+  private void testStickyOnMinScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -214,36 +213,18 @@
   }
 
   @Test
-  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, -2, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, -2, 0, changeKind);
-    }
+  public void stickyOnMaxScore_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    testStickyOnMaxScore();
   }
 
   @Test
-  public void stickyOnMaxScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.save();
-    }
+  public void stickyOnMaxScore_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:max"));
+    testStickyOnMaxScore();
+  }
 
+  private void testStickyOnMaxScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -260,15 +241,19 @@
   }
 
   @Test
-  public void stickyOnCopyValues() throws Exception {
-    TestAccount user2 = accountCreator.user2();
+  public void stickyOnCopyValues_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
+    testStickyOnCopyValues();
+  }
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-      u.save();
-    }
+  @Test
+  public void stickyOnCopyValues_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:\"-1\" OR is:1"));
+    testStickyOnCopyValues();
+  }
+
+  private void testStickyOnCopyValues() throws Exception {
+    TestAccount user2 = accountCreator.user2();
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -288,13 +273,18 @@
   }
 
   @Test
-  public void stickyOnTrivialRebase() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.save();
-    }
+  public void stickyOnTrivialRebase_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnTrivialRebase(true));
+    testStickyOnTrivialRebase();
+  }
 
+  @Test
+  public void stickyOnTrivialRebase_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
+    testStickyOnTrivialRebase();
+  }
+
+  private void testStickyOnTrivialRebase() throws Exception {
     String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -336,12 +326,18 @@
   }
 
   @Test
-  public void stickyOnNoCodeChange() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
+  public void stickyOnNoCodeChange_withoutCopyCondition() throws Exception {
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testStickyOnNoCodeChange();
+  }
 
+  @Test
+  public void stickyOnNoCodeChange_withCopyCondition() throws Exception {
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testStickyOnNoCodeChange();
+  }
+
+  private void testStickyOnNoCodeChange() throws Exception {
     String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -360,14 +356,19 @@
   }
 
   @Test
-  public void stickyOnMergeFirstParentUpdate() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-      u.save();
-    }
+  public void stickyOnMergeFirstParentUpdate_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
+    testStickyOnMergeFirstParentUpdate();
+  }
 
+  @Test
+  public void stickyOnMergeFirstParentUpdate_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
+    testStickyOnMergeFirstParentUpdate();
+  }
+
+  private void testStickyOnMergeFirstParentUpdate() throws Exception {
     String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -386,12 +387,51 @@
   }
 
   @Test
-  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfNoChange(true));
-      u.save();
-    }
+  public void
+      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withoutCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
+    testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate();
+  }
 
+  @Test
+  public void
+      notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
+    testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate();
+  }
+
+  private void testNotStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate()
+      throws Exception {
+    // Create a change with a non-merge commit
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    // Make a NO_CHANGE update (expect that votes are not copied since it's not a merge change).
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, NO_CHANGE);
+    assertVotes(c, user, -0, 0, NO_CHANGE);
+  }
+
+  @Test
+  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withoutCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfNoChange(true));
+    testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated();
+  }
+
+  @Test
+  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated_withCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + NO_CHANGE.name()));
+    testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated();
+  }
+
+  private void testNotStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
@@ -406,27 +446,18 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
   }
 
   @Test
   public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withCopyCondition()
       throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
       throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -450,29 +481,19 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
   }
 
   @Test
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
       throws Exception {
     // create "existing file" and submit it.
     String existingFile = "existing file";
@@ -508,28 +529,19 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
   }
 
   @Test
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
       throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -548,25 +560,51 @@
   public void
       stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+  }
+
+  @Test
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+  }
+
+  private void testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
   }
 
   @Test
   public void
       stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
+  }
+
+  @Test
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withCopyCondition()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase();
+  }
+
+  private void testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
+      throws Exception {
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
     testRepo.reset("HEAD~1");
@@ -598,59 +636,26 @@
   }
 
   @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
-  }
-
-  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
-      throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    vote(user, changeId.toString(), -2, -1);
-
-    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
-    // configured for that label, and list of files didn't change.
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, -2, 0);
-  }
-
   @TestProjectInput(createEmptyCommit = false)
   public void
       stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
   }
 
+  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void
       stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
   }
 
-  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
-      throws Exception {
+  private void
+      testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+          throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -669,29 +674,20 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
   }
 
   @Test
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
   }
 
   private void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
+      testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
           throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -716,28 +712,19 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
   }
 
   @Test
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
       throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -753,156 +740,18 @@
   }
 
   @Test
-  public void removedVotesNotSticky() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, -2, -1);
-
-      // Remove votes by re-voting with 0
-      vote(admin, changeId, 0, 0);
-      vote(user, changeId, 0, 0);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, null);
-      assertVotes(c, user, 0, 0, null);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSets() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-
-    for (int i = 0; i < 5; i++) {
-      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
-    }
-
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
-    // The purpose of this test is to make sure that we compute change kind only against the parent
-    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
-    // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
-    // work in O(num-patch-sets). This test ensures that we aren't regressing.
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-
-    Map<Integer, ObjectId> revisions = new HashMap<>();
-    gApi.changes()
-        .id(changeId)
-        .get()
-        .revisions
-        .forEach(
-            (revId, revisionInfo) ->
-                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
-    assertThat(revisions.size()).isEqualTo(4);
-    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
-    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
-    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));
-
-    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
-    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
-    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
-  }
-
-  @Test
-  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
-      u.save();
-    }
-
-    // Vote max score on PS1
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-
-    // Have someone else vote min score on PS2
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    vote(user, changeId, -2, 0);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // No vote changes on PS3
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // Both users revote on PS4
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    vote(admin, changeId, 1, 1);
-    vote(user, changeId, 1, 1);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 1, 1, REWORK);
-    assertVotes(c, user, 1, 1, REWORK);
-
-    // New approvals shouldn't carry through to PS5
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 0, REWORK);
-    assertVotes(c, user, 0, 0, REWORK);
-  }
-
-  @Test
   public void copyWithListOfFilesUnchanged_withoutCopyCondition() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    copyWithListOfFilesUnchanged();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testCopyWithListOfFilesUnchanged();
   }
 
   @Test
   public void copyWithListOfFilesUnchanged_withCopyCondition() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    copyWithListOfFilesUnchanged();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testCopyWithListOfFilesUnchanged();
   }
 
-  private void copyWithListOfFilesUnchanged() throws Exception {
+  private void testCopyWithListOfFilesUnchanged() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -949,33 +798,36 @@
     assertVotes(c, user, 0, 0);
   }
 
+  // This test doesn't work without copy condition (when
+  // setCopyAllScoresIfListOfFilesDidNotChange=true).
+  // This is because magic files are not ignored for setCopyAllScoresIfListOfFilesDidNotChange.
   @Test
-  public void copyWithListOfFilesUnchangedButAddedMergeList() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
-    Change.Id dummyParentChangeId = changeOperations.newChange().create();
+  public void stickyIfFilesUnchanged_magicFilesAreIgnored() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id dummyParentChangeId = changeOperations.newChange().project(project).create();
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
             .change(parent2ChangeId)
             .create();
 
+    // The change is for a merge commit. It doesn't touch any files, but contains /COMMIT_MSG and
+    // /MERGE_LIST as magic files.
     Map<String, FileInfo> changedFilesFirstPatchset =
         gApi.changes().id(changeId.get()).current().files();
-
     assertThat(changedFilesFirstPatchset.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST");
 
-    // Make a Code-Review vote that should be sticky.
-    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    // Add a Code-Review+2 vote.
+    approve(changeId.toString());
 
+    // Create a new patch set with a non-merge commit.
     changeOperations
         .change(changeId)
         .newPatchset()
@@ -983,25 +835,310 @@
         .patchset(PatchSet.id(dummyParentChangeId, 1))
         .create();
 
+    // Since the new patch set is not a merge commit, it no longer contains the magic /MERGE_LIST
+    // file.
     Map<String, FileInfo> changedFilesSecondPatchset =
         gApi.changes().id(changeId.get()).current().files();
+    assertThat(changedFilesSecondPatchset.keySet())
+        .containsExactly("/COMMIT_MSG"); // /MERGE_LIST is no longer present
 
-    // Only "/MERGE_LIST" was removed.
-    assertThat(changedFilesSecondPatchset.keySet()).containsExactly("/COMMIT_MSG");
-    ApprovalInfo approvalInfo =
-        Iterables.getOnlyElement(
-            gApi.changes().id(changeId.get()).current().votes().get(LabelId.CODE_REVIEW));
-    assertThat(approvalInfo._accountId).isEqualTo(admin.id().get());
-    assertThat(approvalInfo.value).isEqualTo(2);
+    // The only file difference between the 2 patch sets is the magic /MERGE_LIST file.
+    // Since magic files are ignored when checking whether the files between the patch sets differ,
+    // has:unchanged-file should evaluate to true and we expect that the vote was copied.
+    ChangeInfo c = detailedChange(changeId.toString());
+    assertVotes(c, admin, 2, 0);
   }
 
   @Test
-  public void deleteStickyVote() throws Exception {
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
-      u.save();
+  public void approvalsAreStickyIfUploaderMatchesUploaderinCondition() throws Exception {
+    TestAccount userForWhomApprovalsAreSticky =
+        accountCreator.create(
+            /* username= */ null,
+            /* email= */ "userForWhomApprovalsAreSticky@example.com",
+            /* fullName= */ "User For Whom Approvals Are Sticky",
+            /* displayName= */ null);
+    String usersForWhomApprovalsAreStickyUuid =
+        groupOperations
+            .newGroup()
+            .name("Users-for-whom-approvals-are-sticky")
+            .addMember(userForWhomApprovalsAreSticky.id())
+            .create()
+            .get();
+
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("uploaderin:" + usersForWhomApprovalsAreStickyUuid));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user.
+    approve(r.getChangeId());
+
+    // Add Code-Review+1 by user.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    // Create a new patch set by userForWhomApprovalsAreSticky (approavls are sticky).
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(project, userForWhomApprovalsAreSticky);
+    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
+    userTestRepo.reset("ps");
+    amendChange(r.getChangeId(), "refs/for/master", userForWhomApprovalsAreSticky, userTestRepo)
+        .assertOkStatus();
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertThat(c.revisions.get(c.currentRevision).uploader._accountId)
+        .isEqualTo(userForWhomApprovalsAreSticky.id().get());
+
+    // Approvals are sticky.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, 1, 0);
+
+    // Create a new patch set by admin user (approvals are not sticky).
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    // Approvals are not sticky.
+    c = detailedChange(r.getChangeId());
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void approvalsThatMatchApproverinConditionAreSticky() throws Exception {
+    TestAccount userWhoseApprovalsAreSticky = accountCreator.create();
+    String usersWhoseApprovalsAreStickyUuid =
+        groupOperations
+            .newGroup()
+            .name("Users-whose-approvals-are-sticky")
+            .addMember(userWhoseApprovalsAreSticky.id())
+            .create()
+            .get();
+
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("approverin:" + usersWhoseApprovalsAreStickyUuid));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user (not sticky).
+    approve(r.getChangeId());
+
+    // Add Code-Review+1 by userWhoseApprovalsAreSticky (sticky).
+    requestScopeOperations.setApiUser(userWhoseApprovalsAreSticky.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, userWhoseApprovalsAreSticky, 1, 0);
+  }
+
+  @Test
+  public void approvalsThatMatchApproverinConditionAreStickyEvenIfUserCantSeeTheGroup()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    // Verify that user can't see the admin group.
+    requestScopeOperations.setApiUser(user.id());
+    ResourceNotFoundException notFound =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.groups().id(administratorsUUID).get());
+    assertThat(notFound).hasMessageThat().isEqualTo("Not found: " + administratorsUUID);
+
+    requestScopeOperations.setApiUser(admin.id());
+    updateCodeReviewLabel(b -> b.setCopyCondition("approverin:" + administratorsUUID));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user.
+    approve(r.getChangeId());
+
+    // Create a new patch set by user.
+    // Approvals are copied on creation of the new patch set. The approval of the admin user is
+    // expected to be sticky although the group that is configured for the 'approverin' predicate is
+    // not visible to the user.
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
+    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
+    userTestRepo.reset("ps");
+    amendChange(r.getChangeId(), "refs/for/master", user, userTestRepo).assertOkStatus();
+
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertThat(c.revisions.get(c.currentRevision).uploader._accountId).isEqualTo(user.id().get());
+
+    // Assert that the approval of the admin user was copied to the new patch set.
+    assertVotes(c, admin, 2, 0);
+  }
+
+  @Test
+  public void removedVotesNotSticky_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAllScoresOnTrivialRebase(true));
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testRemovedVotesNotSticky();
+  }
+
+  @Test
+  public void removedVotesNotSticky_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testRemovedVotesNotSticky();
+  }
+
+  private void testRemovedVotesNotSticky() throws Exception {
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      // Remove votes by re-voting with 0
+      vote(admin, changeId, 0, 0);
+      vote(user, changeId, 0, 0);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, null);
+      assertVotes(c, user, 0, 0, null);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
     }
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSets_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testStickyAcrossMultiplePatchSets();
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSets_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testStickyAcrossMultiplePatchSets();
+  }
+
+  private void testStickyAcrossMultiplePatchSets() throws Exception {
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+
+    for (int i = 0; i < 5; i++) {
+      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
+    }
+
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance_withoutCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    updateVerifiedLabel(b -> b.setCopyAllScoresIfNoCodeChange(true));
+    testStickyAcrossMultiplePatchSetsDoNotRegressPerformance();
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance_withCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+    testStickyAcrossMultiplePatchSetsDoNotRegressPerformance();
+  }
+
+  private void testStickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
+    // The purpose of this test is to make sure that we compute change kind only against the parent
+    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
+    // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
+    // work in O(num-patch-sets). This test ensures that we aren't regressing.
+
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+
+    Map<Integer, ObjectId> revisions = new HashMap<>();
+    gApi.changes()
+        .id(changeId)
+        .get()
+        .revisions
+        .forEach(
+            (revId, revisionInfo) ->
+                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
+    assertThat(revisions.size()).isEqualTo(4);
+    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
+    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
+    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));
+
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
+  }
+
+  @Test
+  public void copyMinMaxAcrossMultiplePatchSets_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true).setCopyMinScore(true));
+    testCopyMinMaxAcrossMultiplePatchSets();
+  }
+
+  @Test
+  public void copyMinMaxAcrossMultiplePatchSets_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX OR is:MIN"));
+    testCopyMinMaxAcrossMultiplePatchSets();
+  }
+
+  private void testCopyMinMaxAcrossMultiplePatchSets() throws Exception {
+    // Vote max score on PS1
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+
+    // Have someone else vote min score on PS2
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    vote(user, changeId, -2, 0);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // No vote changes on PS3
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // Both users revote on PS4
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    vote(admin, changeId, 1, 1);
+    vote(user, changeId, 1, 1);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 1, 1, REWORK);
+    assertVotes(c, user, 1, 1, REWORK);
+
+    // New approvals shouldn't carry through to PS5
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, REWORK);
+    assertVotes(c, user, 0, 0, REWORK);
+  }
+
+  @Test
+  public void deleteStickyVote_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    testDeleteStickyVote();
+  }
+
+  @Test
+  public void deleteStickyVote_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    testDeleteStickyVote();
+  }
+
+  private void testDeleteStickyVote() throws Exception {
+    String label = LabelId.CODE_REVIEW;
 
     // Vote max score on PS1
     String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
@@ -1016,14 +1153,18 @@
   }
 
   @Test
-  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+  public void canVoteMultipleTimesOnNewPatchsets_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testCanVoteMultipleTimesOnNewPatchsets();
+  }
 
+  @Test
+  public void canVoteMultipleTimesOnNewPatchsets_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testCanVoteMultipleTimesOnNewPatchsets();
+  }
+
+  private void testCanVoteMultipleTimesOnNewPatchsets() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote.
@@ -1043,14 +1184,18 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUpload() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+  public void stickyVoteStoredOnUpload_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnUpload();
+  }
 
+  @Test
+  public void stickyVoteStoredOnUpload_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnUpload();
+  }
+
+  private void testStickyVoteStoredOnUpload() throws Exception {
     PushOneCommit.Result r = createChange();
     // Add a new vote.
     ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
@@ -1084,14 +1229,18 @@
   }
 
   @Test
-  public void stickyVoteStoredOnRebase() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+  public void stickyVoteStoredOnRebase_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnRebase();
+  }
 
+  @Test
+  public void stickyVoteStoredOnRebase_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnRebase();
+  }
+
+  private void testStickyVoteStoredOnRebase() throws Exception {
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
     testRepo.reset("HEAD~1");
@@ -1119,7 +1268,18 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccount() throws Exception {
+  public void stickyVoteStoredOnUploadWithRealAccount_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnUploadWithRealAccount();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccount_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnUploadWithRealAccount();
+  }
+
+  private void testStickyVoteStoredOnUploadWithRealAccount() throws Exception {
     // Give "user" permission to vote on behalf of other users.
     projectOperations
         .project(project)
@@ -1132,13 +1292,6 @@
                 .range(-1, 1))
         .update();
 
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
     PushOneCommit.Result r = createChange();
 
     // Add a new vote as user
@@ -1173,7 +1326,19 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
+  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withoutCopyCondition()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredOnUploadWithRealAccountAndTag();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccountAndTag_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredOnUploadWithRealAccountAndTag();
+  }
+
+  private void testStickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
     // Give "user" permission to vote on behalf of other users.
     projectOperations
         .project(project)
@@ -1186,13 +1351,6 @@
                 .range(-1, 1))
         .update();
 
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
     PushOneCommit.Result r = createChange();
 
     // Add a new vote as user
@@ -1230,14 +1388,18 @@
   }
 
   @Test
-  public void stickyVoteStoredCanBeRemoved() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+  public void stickyVoteStoredCanBeRemoved_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testStickyVoteStoredCanBeRemoved();
+  }
 
+  @Test
+  public void stickyVoteStoredCanBeRemoved_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testStickyVoteStoredCanBeRemoved();
+  }
+
+  private void testStickyVoteStoredCanBeRemoved() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Add a new vote
@@ -1246,7 +1408,7 @@
 
     // Make a new patchset, keeping the Code-Review +2 vote.
     amendChange(r.getChangeId());
-    assertVotes(detailedChange(r.getChangeId()), admin, label, 2, null);
+    assertVotes(detailedChange(r.getChangeId()), admin, LabelId.CODE_REVIEW, 2, null);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
 
@@ -1265,6 +1427,162 @@
     assertThat(nonCopiedSecondPatchsetRemovedVote.copied()).isFalse();
   }
 
+  @Test
+  public void reviewerStickyVotingCanBeRemoved_withoutCopyConfition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyAnyScore(true));
+    testReviewerStickyVotingCanBeRemoved();
+  }
+
+  @Test
+  public void reviewerStickyVotingCanBeRemoved_withCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    testReviewerStickyVotingCanBeRemoved();
+  }
+
+  private void testReviewerStickyVotingCanBeRemoved() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote by user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Make a new patchset, keeping the Code-Review +1 vote.
+    amendChange(r.getChangeId());
+    assertVotes(detailedChange(r.getChangeId()), user, LabelId.CODE_REVIEW, 1, null);
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    assertThat(r.getChange().notes().getApprovalsWithCopied()).isEmpty();
+
+    // Changes message has info about vote removed.
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
+        .contains("Code-Review+1 by User");
+  }
+
+  @Test
+  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, -2, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
+    updateVerifiedLabel(b -> b.setFunction(LabelFunction.NO_BLOCK));
+
+    // This test is covering the backfilling logic for changes which have been submitted, based on
+    // copied approvals, before Gerrit persisted copied votes as Copied-Label footers in NoteDb. It
+    // verifies that for such changes copied approvals are returned from the API even if the copied
+    // votes were not persisted as Copied-Label footers.
+    //
+    // In other words, this test verifies that given a change that was approved by a copied vote and
+    // then submitted and for which the copied approval is not persisted as a Copied-Label footer in
+    // NoteDb the copied approval is backfilled from the corresponding Submitted-With footer that
+    // got written to NoteDb on submit.
+    //
+    // Creating such a change would be possible by running the old Gerrit code from before Gerrit
+    // persisted copied labels as Copied-Label footers. However since this old Gerrit code is no
+    // longer available, the test needs to apply a trick to create a change in this state. It
+    // configures a fake submit rule, that pretends that an approval for a non-sticky label from an
+    // old patch set is still present on the current patch set and allows to submit the change.
+    // Since the label is non-sticky no Copied-Label footer is written for it. On submit the fake
+    // submit rule results in a Submitted-With footer that records the label as approved (although
+    // the label is actually not present on the current patch set). This is exactly the change state
+    // that we would have had by running the old code if submit was based on a copied label. As
+    // result of the backfilling logic we expect that this "copied" label (the label that is
+    // mentioned in the Submitted-With footer) is returned from the API.
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new TestSubmitRule(user.id()))) {
+      // We want to add a vote on PS1, then not copy it to PS2, but include it in submit records
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote on patch-set 1
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      // Upload patch-set 2. Change user's "Verified" vote on PS2.
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("new_file")
+          .content("content")
+          .commitMessage("Upload PS2")
+          .create();
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, 1);
+
+      // Upload patch-set 3
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("another_file")
+          .content("content")
+          .commitMessage("Upload PS3")
+          .create();
+      vote(admin, changeId, 2, 1);
+
+      List<PatchSetApproval> patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+              .stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // There's no verified approval on PS#3.
+      assertThat(
+              patchSetApprovals.stream()
+                  .filter(
+                      a ->
+                          a.accountId().equals(user.id())
+                              && a.label().equals(TestLabels.verified().getName())
+                              && a.patchSetId().get() == 3)
+                  .collect(Collectors.toList()))
+          .isEmpty();
+
+      // Submit the change. The TestSubmitRule will store a "submit record" containing a label
+      // voted by user, but the latest patch-set does not have an approval for this user, hence
+      // it will be copied if we request approvals after the change is merged.
+      requestScopeOperations.setApiUser(admin.id());
+      gApi.changes().id(changeId).current().submit();
+
+      patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+              .stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // Get the copied approval for user on PS3 for the "Verified" label.
+      PatchSetApproval verifiedApproval =
+          patchSetApprovals.stream()
+              .filter(
+                  a ->
+                      a.accountId().equals(user.id())
+                          && a.label().equals(TestLabels.verified().getName())
+                          && a.patchSetId().get() == 3)
+              .collect(MoreCollectors.onlyElement());
+
+      assertCopied(
+          verifiedApproval,
+          /* psId= */ 3,
+          TestLabels.verified().getName(),
+          (short) 1,
+          /* copied= */ true);
+    }
+  }
+
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -1352,4 +1670,44 @@
     assertThat(approval.value()).isEqualTo(value);
     assertThat(approval.copied()).isEqualTo(copied);
   }
+
+  private void updateCodeReviewLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.CODE_REVIEW, update);
+  }
+
+  private void updateVerifiedLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.VERIFIED, update);
+  }
+
+  private void updateLabel(String labelName, Consumer<LabelType.Builder> update) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(labelName, update);
+      u.save();
+    }
+  }
+
+  /**
+   * Test submit rule that always return a passing record with a "Verified" label applied by {@link
+   * TestSubmitRule#userAccountId}.
+   */
+  private static class TestSubmitRule implements SubmitRule {
+    Account.Id userAccountId;
+
+    TestSubmitRule(Account.Id userAccountId) {
+      this.userAccountId = userAccountId;
+    }
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "Verified";
+      label.status = SubmitRecord.Label.Status.OK;
+      label.appliedBy = userAccountId;
+      record.labels = Arrays.asList(label);
+      return Optional.of(record);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
similarity index 84%
rename from javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
rename to javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
index 96db71a..9ceb6bf 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
@@ -41,7 +39,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.Test;
 
-public class ChangeSubmitRequirementIT extends AbstractDaemonTest {
+public class SubmitRequirementCustomRuleIT extends AbstractDaemonTest {
   private static final LegacySubmitRequirement req =
       LegacySubmitRequirement.builder()
           .setType("custom_rule")
@@ -203,37 +201,6 @@
     assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
   }
 
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD,
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS
-      })
-  public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
-    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
-    String changeId = r.getChangeId();
-
-    rule.numberOfEvaluations.set(0);
-    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
-
-    // Submit rules are invoked
-    assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
-  }
-
-  @Test
-  public void submitRuleIsNotInvokedWhenQueryingChangeWithoutExperiment() throws Exception {
-    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
-    String changeId = r.getChangeId();
-
-    rule.numberOfEvaluations.set(0);
-    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
-
-    // Submit rules are not invoked
-    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
-  }
-
   private List<ChangeInfo> queryIsSubmittable() throws Exception {
     return gApi.changes().query("is:submittable project:" + project.get()).get();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
new file mode 100644
index 0000000..865dd6c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -0,0 +1,2903 @@
+// 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.api.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import 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.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRecordInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Test;
+
+@NoHttpd
+@UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
+public class SubmitRequirementIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
+
+  @Test
+  public void submitRecords() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      // Check the default submit record for the code-review label
+      SubmitRecordInfo codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.NOT_READY);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.NEED);
+      assertThat(label.appliedBy).isNull();
+      // Check the custom test record created by the TestSubmitRule
+      SubmitRecordInfo testRecord = Iterables.get(change.submitRecords, 1);
+      assertThat(testRecord.ruleName).isEqualTo("testSubmitRule");
+      assertThat(testRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(testRecord.requirements)
+          .containsExactly(new LegacySubmitRequirementInfo("OK", "fallback text", "type"));
+      assertThat(testRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label testLabel = Iterables.getOnlyElement(testRecord.labels);
+      assertThat(testLabel.label).isEqualTo("label");
+      assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(testLabel.appliedBy).isNull();
+
+      voteLabel(changeId, "Code-Review", 2);
+      // Code review record is satisfied after voting +2
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(label.appliedBy._accountId).isEqualTo(admin.id().get());
+    }
+  }
+
+  @Test
+  public void checkSubmitRequirement_satisfied() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_notApplicable() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ "branch:non-existent",
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ null);
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void checkSubmitRequirement_overridden() throws Exception {
+    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Override-Label")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ null,
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ "label:Override-Label=+1");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+
+    voteLabel(changeId, "Override-Label", 1);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void checkSubmitRequirement_error() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMax() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
+    configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The second requirement is coming from the legacy code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting with a max vote as the uploader will not satisfy the submit requirement.
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting as a non-uploader will satisfy the submit requirement.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is satisfied because there are no votes
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement (coming from the label function definition) is not satisfied. We return
+    // both legacy and non-legacy requirements in this case since their statuses are not identical.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "Code-Review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is still satisfied because -1 is not the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "Code-Review", -2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is now unsatisfied because -2 is the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
+    configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:my-label=MAX,user=non_uploader -label:my-label=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create the change as admin
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
+    voteLabel(changeId, "my-label", -1);
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The other requirement is coming from the code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // user (i.e. non_uploader) votes 1. Requirement is still blocking because of -1 of uploader.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Admin (a.k.a uploader) removes -1. Now requirement is fulfilled.
+    requestScopeOperations.setApiUser(admin.id());
+    voteLabel(changeId, "my-label", 0);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsAny() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=ANY"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "build-cop-override", 1);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectWithStricterRequirement() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectWithLessStrictRequirement()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectAsDisabled() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Custom-Requirement")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Custom-Requirement")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "Custom-Requirement",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_inheritedFromParentProject() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenSRInParentProjectIsInheritedByChildProject()
+      throws Exception {
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in parent project (requires Code-Review=+2 instead of +1).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    Project.NameKey child = createProjectOverAPI("child", project, true, /* submitType= */ null);
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child);
+    PushOneCommit.Result r = createChange(childRepo);
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1).
+    // Will have no effect since parent does not allow override.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project was ignored
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentAddsSRThatDoesNotAllowOverride()
+      throws Exception {
+    // Submit requirement in child project (requires Code-Review=+1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // Add stricter non-overridable submit requirement in parent project (requires Code-Review=+2,
+    // instead of Code-Review=+1)
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentMakesSRNonOverridable()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // Disallow overriding the submit requirement in the parent project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInGrandChildProject_ifGrandParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    Project.NameKey grandChild =
+        createProjectOverAPI("grandChild", project, true, /* submitType= */ null);
+
+    // Override submit requirement in grand child project (requires Code-Review=+2 instead of +1).
+    // Will have no effect since grand parent does not allow override.
+    configSubmitRequirement(
+        grandChild,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    TestRepository<InMemoryRepository> grandChildRepo = cloneProject(grandChild);
+    PushOneCommit.Result r = createChange(grandChildRepo);
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in grand child project was ignored
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overrideOverideExpression() throws Exception {
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Create Code-Review-Override label
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "NoOp";
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+
+    // Allow to vote on the Code-Review-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Override submit requirement in project (requires Code-Review-Override+1 as override instead
+    // of build-cop-override+1).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:Code-Review-Override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review-Override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review-Override+1 was enough to fulfill the override expression of the requirement
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately()
+      throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // Override submit requirement in project (allow uploaders to self approve).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // the self approval from the uploader is no longer ignored, hence the submit requirement is
+    // satisfied now
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    // since the change is submittable now we expect the submit action to be returned
+    Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+    assertThat(actions).containsKey("submit");
+    ActionInfo submitAction = actions.get("submit");
+    assertThat(submitAction.enabled).isTrue();
+  }
+
+  @Test
+  public void
+      submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately_staleIndex()
+          throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // disable change index writes so that the change in the index gets stale when the new submit
+    // requirement is added
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+      // Override submit requirement in project (allow uploaders to self approve).
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=MAX"))
+              .setAllowOverrideInChildProjects(true)
+              .build());
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // the self approval from the uploader is no longer ignored, hence the submit requirement is
+      // satisfied now
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // since the change is submittable now we expect the submit action to be returned
+      Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+      assertThat(actions).containsKey("submit");
+      ActionInfo submitAction = actions.get("submit");
+      assertThat(submitAction.enabled).isTrue();
+    }
+  }
+
+  @Test
+  public void submitRequirement_partiallyOverriddenSRIsIgnored() throws Exception {
+    // Create build-cop-override label
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "NoOp";
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("build-cop-override").create(input).get();
+
+    // Allow to vote on the build-cop-override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("build-cop-override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Create Code-Review-Override label
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+
+    // Allow to vote on the Code-Review-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Override submit requirement in project (requires Code-Review-Override+1 as override instead
+    // of build-cop-override+1), but do not set all required properties (submittability expression
+    // is missing). We update the project.config file directly in the remote repository, since
+    // trying to push such a submit requirement would be rejected by the commit validation.
+    projectOperations
+        .project(project)
+        .forInvalidation()
+        .addProjectConfigUpdater(
+            config ->
+                config.setString(
+                    ProjectConfig.SUBMIT_REQUIREMENT,
+                    "Code-Review",
+                    ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+                    "label:Code-Review-Override=+1"))
+        .invalidate();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review-Override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // The override expression in the project is satisfied, but it's ignored since the SR is
+    // incomplete.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "build-cop-override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // The submit requirement is overridden now (the override expression in the child project is
+    // ignored)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_storedForClosedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "Code-Review", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertSubmitRequirementResult(
+          result,
+          "Code-Review",
+          SubmitRequirementResult.Status.SATISFIED,
+          /* submitExpr= */ "label:Code-Review=MAX",
+          SubmitRequirementExpressionResult.Status.PASS);
+
+      // Adding comments does not affect the stored SRs.
+      addComment(r.getChangeId(), /* file= */ "foo");
+      notes = notesFactory.create(project, r.getChange().getId());
+      result = notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertSubmitRequirementResult(
+          result,
+          "Code-Review",
+          SubmitRequirementResult.Status.SATISFIED,
+          /* submitExpr= */ "label:Code-Review=MAX",
+          SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(notes.getHumanComments()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void submitRequirement_storedForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "Code-Review", 2);
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(r.getChangeId()).abandon();
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(result.submittabilityExpressionResult().get().status())
+          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+          .isEqualTo("label:Code-Review=MAX");
+    }
+  }
+
+  @Test
+  public void submitRequirement_loadedFromTheLatestRevisionNoteForClosedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Upload a second patch-set, fulfill the CR submit requirement.
+      amendChange(changeId, "refs/for/master", user, repo);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.revisions).hasSize(2);
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // Abandon the change.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_abandonRestoreUpdateMerge() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Update the change.
+      amendChange(changeId, "refs/for/master", user, repo);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.revisions).hasSize(2);
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // Merge the change.
+      gApi.changes().id(changeId).current().submit();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_returnsEmpty_forAbandonedChangeWithPreviouslyStoredSRs()
+      throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Clear SRs for the project and update code-review label to be non-blocking.
+      clearSubmitRequirements(project);
+      LabelType cr =
+          TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().upsertLabelType(cr);
+        u.save();
+      }
+
+      // Restore the change. No SRs apply.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+
+      // Abandon the change. Still, no SRs apply.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+    }
+  }
+
+  @Test
+  public void submitRequirement_returnsEmpty_forMergedChangeWithPreviouslyStoredSRs()
+      throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Clear SRs for the project and update code-review label to be non-blocking.
+      clearSubmitRequirements(project);
+      LabelType cr =
+          TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().upsertLabelType(cr);
+        u.save();
+      }
+
+      // Restore the change. No SRs apply.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+
+      // Merge the change. Still, no SRs apply.
+      gApi.changes().id(changeId).current().submit();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+    }
+  }
+
+  @Test
+  public void submitRequirement_withMultipleAbandonAndRestore() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Abandon the change again.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore, vote CR=+2, and abandon again. Make sure the requirement is now satisfied.
+      gApi.changes().id(changeId).restore();
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_retrievedFromNoteDbForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).abandon();
+
+      // Add another submit requirement. This will not get returned for the abandoned change, since
+      // we return the state of the SR results when the change was abandoned.
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("New-Requirement")
+              .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+
+      // Restore the change, the new requirement will show up
+      gApi.changes().id(changeId).restore();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+
+      // Abandon again, make sure the new requirement was persisted
+      gApi.changes().id(changeId).abandon();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+    }
+  }
+
+  @Test
+  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    // Add new submit requirement
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // The new "Verified" submit requirement is not returned, since this change is closed
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void
+      submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:build-cop-override=MAX -label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change. Vote to fulfill all requirements.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Only non-legacy bco is returned.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertThat(change.submittable).isTrue();
+
+    // Merge the change. Submit requirements are still the same.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+  }
+
+  @Test
+  public void
+      submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Two instances of bco will be returned since their status is not matching.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(3);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ true,
+        // MAX_WITH_BLOCK function was translated to a submittability expression.
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MIN");
+    assertThat(change.submittable).isFalse();
+  }
+
+  @Test
+  public void submitRequirements_skippedIfLegacySRIsBasedOnOptionalLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.MAY);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirement_notSkippedIfLegacySRIsBasedOnNonOptionalLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // 1. Project has two legacy requirements: Code-Review and bco. Both unsatisfied.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // 2. Vote +1 on bco. bco becomes satisfied
+    voteLabel(changeId, "build-cop-override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 3. Vote +1 on Code-Review. Code-Review becomes satisfied
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 4. Merge the change. Submit requirements status is presented from NoteDb.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    // Legacy submit records are returned as submit requirements.
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+    gApi.changes().id(changeId).current().submit();
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_applicabilityExpressionIsAlwaysHidden() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("branch:refs/heads/master"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertSubmitRequirementExpression(
+        requirement.applicabilityExpressionResult,
+        /* expression= */ null,
+        /* passingAtoms= */ null,
+        /* failingAtoms= */ null,
+        /* fulfilled= */ true);
+    assertThat(requirement.submittabilityExpressionResult).isNotNull();
+  }
+
+  @Test
+  public void submitRequirement_nonApplicable_submittabilityAndOverrideNotEvaluated()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of("branch:refs/heads/non-existent"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:" + project.get()))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertSubmitRequirementExpression(
+        requirement.applicabilityExpressionResult,
+        /* expression= */ null,
+        /* passingAtoms= */ null,
+        /* failingAtoms= */ null,
+        /* fulfilled= */ false);
+    assertThat(requirement.submittabilityExpressionResult).isNull();
+    assertThat(requirement.overrideExpressionResult).isNull();
+  }
+
+  @Test
+  public void submitRequirement_emptyApplicable_submittabilityAndOverrideEvaluated()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(Optional.empty())
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:non-existent"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertThat(requirement.applicabilityExpressionResult).isNull();
+    assertSubmitRequirementExpression(
+        requirement.submittabilityExpressionResult,
+        /* expression= */ SubmitRequirementExpression.maxCodeReview().expressionString(),
+        /* passingAtoms= */ ImmutableList.of(
+            SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* failingAtoms= */ ImmutableList.of(),
+        /* fulfilled= */ true);
+    assertSubmitRequirementExpression(
+        requirement.overrideExpressionResult,
+        /* expression= */ "project:non-existent",
+        /* passingAtoms= */ ImmutableList.of(),
+        /* failingAtoms= */ ImmutableList.of("project:non-existent"),
+        /* fulfilled= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overriden_submittabilityEvaluated() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(Optional.empty())
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:" + project.get()))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 1);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream()
+            .filter(sr -> !sr.isLegacy)
+            .collect(MoreCollectors.onlyElement());
+    assertThat(requirement.applicabilityExpressionResult).isNull();
+    assertSubmitRequirementExpression(
+        requirement.submittabilityExpressionResult,
+        /* expression= */ SubmitRequirementExpression.maxCodeReview().expressionString(),
+        /* passingAtoms= */ ImmutableList.of(),
+        /* failingAtoms= */ ImmutableList.of(
+            SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* fulfilled= */ false);
+    assertSubmitRequirementExpression(
+        requirement.overrideExpressionResult,
+        /* expression= */ "project:" + project.get(),
+        /* passingAtoms= */ ImmutableList.of("project:" + project.get()),
+        /* failingAtoms= */ ImmutableList.of(),
+        /* fulfilled= */ true);
+  }
+
+  @Test
+  public void submitRequirements_eliminatesDuplicatesForLegacyNonMatchingSRs() throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have two different submit rules that
+    // return the same label name, one as "OK" and the other as "NEED". The submit requirements
+    // API favours the blocking entry and returns one SR result with status=UNSATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    SubmitRule r2 =
+        createSubmitRule("r2", SubmitRecord.Status.NOT_READY, "CR", SubmitRecord.Label.Status.NEED);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1).add(r2)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.UNSATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_eliminatesDuplicatesForLegacyMatchingSRs() throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have two different submit rules that
+    // return the same label name, but both are fulfilled (i.e. they both allow submission). The
+    // submit requirements API returns one SR result with status=SATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    SubmitRule r2 =
+        createSubmitRule("r2", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.MAY);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1).add(r2)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_eliminatesMultipleDuplicatesForLegacyMatchingSRs()
+      throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have five different submit rules that
+    // return the same label name, all with an "OK" status. The submit requirements API returns
+    // a single SR result with status=SATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    try (Registration registration = extensionRegistry.newRegistration()) {
+      IntStream.range(0, 5)
+          .forEach(
+              i ->
+                  registration.add(
+                      createSubmitRule(
+                          "r" + i, SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK)));
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirement_duplicateSubmitRequirement_sameCase() throws Exception {
+    // Define 2 submit requirements with exact same name but different submittability expression.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = repo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = repo.getRevWalk().parseCommit(ref.getObjectId());
+      RevObject blob = repo.get(head.getTree(), ProjectConfig.PROJECT_CONFIG);
+      byte[] data = repo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+      String projectConfig = RawParseUtils.decode(data);
+
+      repo.update(
+          RefNames.REFS_CONFIG,
+          repo.commit()
+              .parent(head)
+              .message("Set project config")
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  projectConfig
+                      // JGit parses this as a list value:
+                      // submit-requirement.Code-Review.submittableIf =
+                      //     [label:Code-Review=+2, label:Code-Review=+1]
+                      // if getString is used to read submittableIf JGit returns the last value
+                      // (label:Code-Review=+1)
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+2\n"
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+1\n"));
+    }
+    projectCache.evict(project);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // The submit requirement is fulfilled now, since label:Code-Review=+1 applies as submittability
+    // expression (see comment above)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_duplicateSubmitRequirement_differentCase() throws Exception {
+    // Define 2 submit requirements with same name but different case and different submittability
+    // expression.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = repo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = repo.getRevWalk().parseCommit(ref.getObjectId());
+      RevObject blob = repo.get(head.getTree(), ProjectConfig.PROJECT_CONFIG);
+      byte[] data = repo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+      String projectConfig = RawParseUtils.decode(data);
+
+      repo.update(
+          RefNames.REFS_CONFIG,
+          repo.commit()
+              .parent(head)
+              .message("Set project config")
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  projectConfig
+                      // ProjectConfig processes the submit requirements in the order in which they
+                      // appear (1. Code-Review, 2. code-review) and ignores any further submit
+                      // requirement if its name case-insensitively matches the name of a submit
+                      // requirement that has already been seen. This means the Code-Review submit
+                      // requirement applies and the code-review submit requirement is ignored.
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+2\n"
+                      + "[submit-requirement \"code-review\"]\n"
+                      + "    submittableIf = label:Code-Review=+1\n"));
+    }
+    projectCache.evict(project);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Still not satisfied since the Code-Review submit requirement with label:Code-Review=+2 as
+    // submittability expression applies (see comment above).
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // The submit requirement is fulfilled now, since label:Code-Review=+2 applies as submittability
+    // expression (see comment above)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overrideInheritedSRWithDifferentNameCasing() throws Exception {
+    // Define submit requirement in root project and allow override.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Define a submit requirement with the same name in the child project that differs by case and
+    // has a different submittability expression (requires Code-Review=+1 instead of +2).
+    // This overrides the inherited submit requirement with the same name, although the case is
+    // different.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement since the override applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_cannotOverrideNonOverridableInheritedSRWithDifferentNameCasing()
+      throws Exception {
+    // Define submit requirement in root project and disallow override.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Define a submit requirement with the same name in the child project that differs by case and
+    // has a different submittability expression (requires Code-Review=+1 instead of +2).
+    // This is ignored since the inherited submit requirement with the same name (different case)
+    // disallows overriding.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Still not satisfied since the override is ignored.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void globalSubmitRequirement_storedForClosedChanges() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("global-submit-requirement")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.UNSATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      gApi.changes().id(changeId).current().submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(result.submittabilityExpressionResult().get().status())
+          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+          .isEqualTo("topic:test");
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideNotAllowed_globalEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote does not satisfy submit requirement, because the global definition is evaluated.
+      voteLabel(changeId, "CoDe-reView", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+      // In addition, the legacy submit requirement is emitted, since the status mismatch
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      // Setting the topic satisfies the global definition.
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideAllowed_projectRequirementEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Setting the topic does not satisfy submit requirement, because the project definition is
+      // evaluated.
+      gApi.changes().id(changeId).topic("test");
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // There is no mismatch with legacy submit requirement, so the single result is emitted.
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Voting satisfies the project definition.
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusMatches_globalReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(/*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated, but only the global is returned, since both are satisfied
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementWithIgnoreSelfApproval() throws Exception {
+    LabelType verified =
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    verified = verified.toBuilder().setIgnoreSelfApproval(true).build();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(verified.getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // The DefaultSubmitRule emits an "OK" submit record for Verified, while the
+    // ignoreSelfApprovalRule emits a "NEED" submit record. The "submit requirements" adapter merges
+    // both results and returns the blocking one only.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.id().toString());
+
+    voteLabel(changeId, verified.getName(), +1);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    Collection<SubmitRequirementResultInfo> submitRequirements = changeInfo.submitRequirements;
+    assertSubmitRequirementStatus(
+        submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusDoesNotMatch_bothRecordsReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated and both are returned, since result mismatch
+      voteLabel(changeId, "Code-Review", 2);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(changeId).topic("test");
+      gApi.changes().id(changeId).reviewer(admin.id().toString()).deleteVote(LabelId.CODE_REVIEW);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirements_disallowsTheIsSubmittableOperator() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Wrong-Req")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:submittable"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("Wrong-Req"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult.errorMessage)
+        .isEqualTo("Operator 'is:submittable' cannot be used in submit requirement expressions.");
+  }
+
+  @Test
+  public void submitRequirements_forcedByDirectSubmission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("My-Requirement")
+            // Submit requirement is always unsatisfied, but we are going to bypass it.
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    pushFactory.create(admin.newIdent(), testRepo, changeId).to("refs/for/master%submit");
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "My-Requirement", Status.FORCED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.FORCED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_evaluatedWithInternalUserCredentials() throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = "invisible-group";
+    in.visibleToAll = false;
+    in.ownerId = adminGroupUuid().get();
+    gApi.groups().create(in);
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("My-Requirement")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("ownerin:invisible-group"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    requestScopeOperations.setApiUser(user.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("My-Requirement"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirements_submittedTogetherWithoutLegacySubmitRequirements()
+      throws Exception {
+    // Add a code review submit requirement and mark the 'Code-Review' label function to be
+    // non-blocking.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    LabelType cr = TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(cr);
+      u.save();
+    }
+
+    // Create two changes in a chain.
+    createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    // Make sure the CR requirement is unsatisfied.
+    String changeId = r2.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    List<ChangeInfo> changeInfos = gApi.changes().id(changeId).submittedTogether();
+    assertThat(changeInfos).hasSize(2);
+    assertThat(
+            changeInfos.stream()
+                .map(c -> c.submittable)
+                .distinct()
+                .collect(MoreCollectors.onlyElement()))
+        .isFalse();
+  }
+
+  @Test
+  public void queryChangesBySubmitRequirementResultUsingTheLabelPredicate() throws Exception {
+    // Create a non-blocking label and a submit-requirement that necessitates voting on this label.
+    configLabel("LC", LabelFunction.NO_OP);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("LC").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("LC")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:LC=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    List<ChangeInfo> changeInfos = gApi.changes().query("label:LC=NEED").get();
+    assertThat(changeInfos).hasSize(1);
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+    assertThat(gApi.changes().query("label:LC=OK").get()).isEmpty();
+    // case does not matter
+    changeInfos = gApi.changes().query("label:lc=NEED").get();
+    assertThat(changeInfos).hasSize(1);
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+
+    voteLabel(r.getChangeId(), "LC", +1);
+    changeInfos = gApi.changes().query("label:LC=OK").get();
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+    assertThat(gApi.changes().query("label:LC=NEED").get()).isEmpty();
+  }
+
+  @Test
+  public void queryingChangesWithSubmitRequirementOptionDoesNotTouchDatabase() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            // Always not submittable
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+    assertThat(changeInfo.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy = */ false);
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy = */ true);
+
+    requestScopeOperations.setApiUser(user.id());
+    try (AutoCloseable ignored = disableNoteDb()) {
+      List<ChangeInfo> changeInfos =
+          gApi.changes()
+              .query()
+              .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+              .withOptions(
+                  new ImmutableSet.Builder<ListChangesOption>()
+                      .addAll(IndexPreloadingUtil.DASHBOARD_OPTIONS)
+                      .add(ListChangesOption.SUBMIT_REQUIREMENTS)
+                      .build())
+              .get();
+      assertThat(changeInfos).hasSize(1);
+      assertSubmitRequirementStatus(
+          changeInfos.get(0).submitRequirements,
+          "Code-Review",
+          Status.UNSATISFIED,
+          /* isLegacy = */ false);
+      assertSubmitRequirementStatus(
+          changeInfos.get(0).submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy = */ true);
+    }
+  }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void assertSubmitRequirementResult(
+      SubmitRequirementResult result,
+      String srName,
+      SubmitRequirementResult.Status status,
+      String submitExpr,
+      SubmitRequirementExpressionResult.Status submitStatus) {
+    assertThat(result.submitRequirement().name()).isEqualTo(srName);
+    assertThat(result.status()).isEqualTo(status);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo(submitExpr);
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(submitStatus);
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy,
+      String submittabilityCondition) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy
+          && result.submittabilityExpressionResult.expression.equals(submittabilityCondition)) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
+                .collect(toImmutableList())));
+  }
+
+  private void assertSubmitRequirementExpression(
+      SubmitRequirementExpressionInfo result,
+      @Nullable String expression,
+      @Nullable List<String> passingAtoms,
+      @Nullable List<String> failingAtoms,
+      boolean fulfilled) {
+    assertThat(result.expression).isEqualTo(expression);
+    if (passingAtoms == null) {
+      assertThat(result.passingAtoms).isNull();
+    } else {
+      assertThat(result.passingAtoms).containsExactlyElementsIn(passingAtoms);
+    }
+    if (failingAtoms == null) {
+      assertThat(result.failingAtoms).isNull();
+    } else {
+      assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
+    }
+    assertThat(result.fulfilled).isEqualTo(fulfilled);
+  }
+
+  private Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
+    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
+    return project;
+  }
+
+  private static SubmitRule createSubmitRule(
+      String ruleName,
+      SubmitRecord.Status srStatus,
+      String labelName,
+      SubmitRecord.Label.Status labelStatus) {
+    return changeData -> {
+      SubmitRecord r = new SubmitRecord();
+      r.ruleName = ruleName;
+      r.status = srStatus;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = labelName;
+      label.status = labelStatus;
+      r.labels = Arrays.asList(label);
+      return Optional.of(r);
+    };
+  }
+
+  /** Returns a hard-coded submit record containing all fields. */
+  private static class TestSubmitRule implements SubmitRule {
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "label";
+      label.status = SubmitRecord.Label.Status.OK;
+      record.labels = Arrays.asList(label);
+      record.requirements =
+          Arrays.asList(
+              LegacySubmitRequirement.builder()
+                  .setType("type")
+                  .setFallbackText("fallback text")
+                  .build());
+      return Optional.of(record);
+    }
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String submittabilityExpression) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submittabilityExpression;
+    return input;
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String applicableIf, String submittableIf, String overrideIf) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.applicabilityExpression = applicableIf;
+    input.submittabilityExpression = submittableIf;
+    input.overrideExpression = overrideIf;
+    return input;
+  }
+
+  private void addComment(String changeId, String file) throws Exception {
+    ReviewInput in = new ReviewInput();
+    CommentInput ci = new CommentInput();
+    ci.path = file;
+    ci.message = "message";
+    ci.line = 1;
+    in.comments = ImmutableMap.of("foo", ImmutableList.of(ci));
+    gApi.changes().id(changeId).current().review(in);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
new file mode 100644
index 0000000..a443739
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -0,0 +1,216 @@
+// 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.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
+public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private SubmitRequirementsEvaluator submitRequirementsEvaluator;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+
+  private final LabelType label =
+      label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+
+  private final LabelType pLabel =
+      label("Custom-Label2", value(1, "Positive"), value(0, "No score"));
+
+  @Before
+  public void setUp() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(pLabel.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(label);
+      u.getConfig().upsertLabelType(pLabel);
+      u.save();
+    }
+  }
+
+  @Test
+  public void distinctVoters_sameUserVotesOnDifferentLabels_fails() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+
+    // Same user votes on both labels
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_distinctUsersOnDifferentLabels_passes() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyMaxVotesRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyMinVotesRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", -1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MIN,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(c1.toString()).current().review(ReviewInput.reject());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MIN,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyExactValueRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=1,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=1,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_valueIsOptional() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", -1));
+    requestScopeOperations.setApiUser(admin.id());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],count>1\"", c1);
+    recommend(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_moreThanTwoLabels() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label2", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_moreThanTwoLabels_moreThanTwoUsers() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label2", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
+    Account.Id tester = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(tester);
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
+  }
+
+  private void assertMatching(String requirement, Change.Id change) {
+    assertThat(evaluate(requirement, change).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private void assertNotMatching(String requirement, Change.Id change) {
+    assertThat(evaluate(requirement, change).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private SubmitRequirementExpressionResult evaluate(String requirement, Change.Id change) {
+    ChangeData cd = changeDataFactory.create(project, change);
+    return submitRequirementsEvaluator.evaluateExpression(
+        SubmitRequirementExpression.create(requirement), cd);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
index 636b71d..af95e7e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -44,7 +45,7 @@
             recordsBeforeSubmission.stream()
                 .map(record -> record.ruleName)
                 .collect(Collectors.toList()))
-        .containsExactly("gerrit~DefaultSubmitRule");
+        .containsExactly(DefaultSubmitRule.RULE_NAME);
     gApi.changes().id(r.getChangeId()).current().submit();
     // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
     // this would show up as blocking submission.
@@ -67,7 +68,7 @@
             recordsBeforeSubmission.stream()
                 .map(record -> record.ruleName)
                 .collect(Collectors.toList()))
-        .containsExactly("gerrit~DefaultSubmitRule");
+        .containsExactly(DefaultSubmitRule.RULE_NAME);
     gApi.changes().id(r.getChangeId()).current().submit();
     // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
     // this would show up as blocking submission.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 5124d11..77582c6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
@@ -35,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import java.util.HashSet;
@@ -59,8 +61,8 @@
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
               value(0, "No score"),
-              value(-1, "I would prefer that you didn't submit this"),
-              value(-2, "Do not submit"));
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
       codeReview.setCopyAnyScore(true);
       u.getConfig().upsertLabelType(codeReview.build());
       u.save();
@@ -345,8 +347,8 @@
   }
 
   @Test
-  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
-  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void autoGeneratedPostSubmitDiffIsPartOfTheCommentSizeLimit() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
@@ -356,35 +358,69 @@
     // Post a submit diff that is almost the cumulativeCommentSizeLimit
     gApi.changes().id(changeId.get()).current().submit();
     assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
-        .doesNotContain("many unreviewed changes");
+        .doesNotContain("The diff is too large to show. Please review the diff");
 
-    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // unrelated comment and change message posting doesn't work, since the post submit diff is
     // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
-    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
-    // 800 is not counted).
-    String message = new String(new char[400]).replace("\0", "a");
+    // 800 + 9500 > 10k.
+    String message = new String(new char[9500]).replace("\0", "a");
     ReviewInput reviewInput = new ReviewInput().message(message);
     CommentInput commentInput = new CommentInput();
     commentInput.line = 1;
-    commentInput.message = message;
     commentInput.path = "file";
     reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
 
-    gApi.changes().id(changeId.get()).current().review(reviewInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).current().review(reviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Exceeding maximum cumulative size of comments and change messages");
   }
 
   @Test
-  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
   public void postSubmitDiffCannotBeTooBig() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
 
-    String content = new String(new char[1100]).replace("\0", "a");
+    // max post submit diff size is 300k
+    String content = new String(new char[320000]).replace("\0", "a");
 
     changeOperations.change(changeId).newPatchset().file("file").content(content).create();
 
-    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    // Post submit diff is over the postSubmitDiffSizeLimit (300k).
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted with unreviewed changes in the following "
+                + "files:\n\n```\nThe name of the file: file\nInsertions: 1, Deletions: 1.\n\nThe"
+                + " diff is too large to show. Please review the diff.\n```\n");
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void postSubmitDiffCannotBeTooBigWithLargeComments() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // unrelated comment taking up most of the space, making post submit diff shorter.
+    String message = new String(new char[9700]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+    String content = new String(new char[500]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, since the comment took most of
+    // the space (even though the post submit diff is not limited).
     gApi.changes().id(changeId.get()).current().submit();
     assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
         .isEqualTo(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
index 079d43e9..27f6111 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -42,7 +42,7 @@
     assertThat(toBoolean(info.options.visibleToAll)).isEqualTo(group.isVisibleToAll());
     assertThat(info.description).isEqualTo(group.getDescription());
     assertThat(Url.decode(info.ownerId)).isEqualTo(group.getOwnerGroupUUID().get());
-    assertThat(info.createdOn).isEqualTo(group.getCreatedOn());
+    assertThat(info.createdOn.toInstant()).isEqualTo(group.getCreatedOn());
   }
 
   public static boolean toBoolean(Boolean b) {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 4cbc36b..63b67f8 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -109,8 +109,10 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Stream;
@@ -557,14 +559,17 @@
     assertThrows(AuthException.class, () -> gApi.groups().create(name("newGroup")));
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void createdOnFieldIsPopulatedForNewGroup() throws Exception {
     // NoteDb allows only second precision.
-    Timestamp testStartTime = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+    Instant testStartTime = TimeUtil.truncateToSecond(TimeUtil.now());
     String newGroupName = name("newGroup");
     GroupInfo group = gApi.groups().create(newGroupName).get();
 
-    assertThat(group.createdOn).isAtLeast(testStartTime);
+    assertThat(group.createdOn.toInstant()).isAtLeast(testStartTime);
   }
 
   @Test
@@ -606,7 +611,7 @@
     assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
 
     group = gApi.groups().id(anonymousUsersGroup.getName()).get();
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+    assertThat(group.id).isEqualTo(Url.encode(anonymousUsersGroup.getUUID().get()));
   }
 
   @Test
@@ -614,7 +619,7 @@
     GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     GroupInfo group = gApi.groups().id("Anonymous Users").get();
     assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+    assertThat(group.id).isEqualTo(Url.encode(anonymousUsersGroup.getUUID().get()));
   }
 
   @Test
@@ -1603,6 +1608,9 @@
     return createCommit(repo, commitMessage, null);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private ObjectId createCommit(Repository repo, String commitMessage, @Nullable ObjectId treeId)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
@@ -1610,7 +1618,7 @@
         treeId = oi.insert(Constants.OBJ_TREE, new byte[] {});
       }
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      PersonIdent ident = new PersonIdent(serverIdent.get(), Date.from(TimeUtil.now()));
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
index 744cc2a..fe0ab2a 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.api.plugin;
 
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -34,9 +36,9 @@
   @Override
   protected void afterTest() throws Exception {}
 
-  @Test(expected = MissingMandatoryPluginsException.class)
+  @Test
   @GerritConfig(name = "plugins.mandatory", value = "my-mandatory-plugin")
   public void shouldFailToStartGerritWhenMandatoryPluginsAreMissing() throws Exception {
-    super.beforeTest(testDescription);
+    assertThrows(MissingMandatoryPluginsException.class, () -> super.beforeTest(testDescription));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 18e192d..5a024cc 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -23,6 +23,7 @@
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -34,6 +35,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -43,10 +45,13 @@
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
@@ -433,6 +438,76 @@
     assertThat(changeInfo.topic).isEqualTo(input.topic);
   }
 
+  @Test
+  public void cherryPickOnTopOfOpenChange() throws Exception {
+    BranchNameKey srcBranch = BranchNameKey.create(project, "master");
+
+    // Create a target branch
+    BranchNameKey destBranch = BranchNameKey.create(project, "foo");
+    createBranch(destBranch);
+
+    // Create base change on the target branch
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch.shortName());
+    String base = r.getCommit().name();
+    int baseChangeNumber = r.getChange().getId().get();
+
+    // Create commit to cherry-pick on the source branch (no change exists for this commit)
+    String changeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    RevCommit commitToCherryPick;
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      commitToCherryPick =
+          tr.commit()
+              .parent(repo.parseCommit(repo.exactRef(srcBranch.branch()).getObjectId()))
+              .message(String.format("Commit to be cherry-picked\n\nChange-Id: %s\n", changeId))
+              .add("file.txt", "content")
+              .create();
+      tr.branch(srcBranch.branch()).update(commitToCherryPick);
+    }
+
+    // Perform the cherry-pick (cherry-pick on top of the base change)
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch.shortName();
+    input.base = base;
+    ChangeInfo cherryPickChange =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.name())
+            .cherryPick(input)
+            .get();
+
+    // Verify that a new change in destination branch was created.
+    assertThat(cherryPickChange._number).isGreaterThan(baseChangeNumber);
+    assertThat(cherryPickChange.branch).isEqualTo(destBranch.shortName());
+    assertThat(cherryPickChange.revisions).hasSize(1);
+    assertThat(cherryPickChange.messages).hasSize(1);
+
+    // Verify that the Change-Id of the cherry-picked commit is used for the cherry pick change.
+    assertThat(cherryPickChange.changeId).isEqualTo(changeId);
+
+    // Verify that cherry-pick-of is not set, since we cherry-picked a commit and not a change.
+    assertThat(cherryPickChange.cherryPickOfChange).isNull();
+    assertThat(cherryPickChange.cherryPickOfPatchSet).isNull();
+
+    // Verify that the message of the cherry-picked commit was used for the cherry-pick change.
+    RevisionInfo revInfo = cherryPickChange.revisions.get(cherryPickChange.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(commitToCherryPick.getFullMessage());
+
+    // Verify that the provided base commit is the parent commit of the cherry pick revision.
+    assertThat(revInfo.commit.parents).hasSize(1);
+    assertThat(revInfo.commit.parents.get(0).commit).isEqualTo(input.base);
+
+    // Verify that the related changes contain the base change and the cherry-pick change (no matter
+    // for which of these changes the related changes are retrieved).
+    assertThat(gApi.changes().id(cherryPickChange._number).current().related().changes)
+        .comparingElementsUsing(hasId())
+        .containsExactly(baseChangeNumber, cherryPickChange._number);
+    assertThat(gApi.changes().id(baseChangeNumber).current().related().changes)
+        .comparingElementsUsing(hasId())
+        .containsExactly(baseChangeNumber, cherryPickChange._number);
+  }
+
   private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
     return gApi.projects().name(project.get()).commit(id.name()).includedIn();
   }
@@ -452,4 +527,9 @@
   private void createLightWeightTag(String tagName) throws Exception {
     pushHead(testRepo, RefNames.REFS_TAGS + tagName, false, false);
   }
+
+  private static Correspondence<RelatedChangeAndCommitInfo, Integer> hasId() {
+    return NullAwareCorrespondence.transforming(
+        relatedChangeAndCommitInfo -> relatedChangeAndCommitInfo._changeNumber, "hasId");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 9c74c48..58d1628 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -928,6 +928,21 @@
   }
 
   @Test
+  public void invalidJgitConfigFile_canNotPushToProjectConfig() throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(allProjects);
+    GitUtil.fetch(repo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    repo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), repo, "Subject", "project.config", "jgit config")
+            .to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus("invalid project configuration");
+    r.assertMessage(
+        "Invalid config file project.config in project All-Projects in branch refs/meta/config");
+    r.assertMessage("Invalid line in config file");
+  }
+
+  @Test
   public void getProjectThatHasLabelDefinitionWithDuplicateValues() throws Exception {
     // Update the definition of the Code-Review label so that it has the value "+1 LGTM" twice.
     // This update bypasses all validation checks so that the duplicate label value doesn't get
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index e45d95c..a625a70 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -21,6 +21,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -50,6 +51,7 @@
   @Inject private IndexConfig indexConfig;
   @Inject private StalenessChecker stalenessChecker;
   @Inject private ProjectOperations projectOperations;
+  @Inject private IndexOperations.Project projectIndexOperations;
 
   private static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
@@ -124,7 +126,7 @@
         assertThrows(
             StorageException.class,
             () -> {
-              try (AutoCloseable ignored = disableProjectIndex()) {
+              try (AutoCloseable ignored = projectIndexOperations.disableReadsAndWrites()) {
                 try (ProjectConfigUpdate u = updateProject(project)) {
                   update.accept(u.getConfig());
                   u.save();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 7197425..ba45fb2 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -22,23 +22,31 @@
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
+import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation;
 import com.google.gerrit.acceptance.testsuite.change.TestPatchset;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -47,11 +55,12 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Ignore;
 import org.junit.Test;
 
 public class PortedCommentsIT extends AbstractDaemonTest {
-
   @Inject private ChangeOperations changeOps;
   @Inject private AccountOperations accountOps;
   @Inject private RequestScopeOperations requestScopeOps;
@@ -143,6 +152,39 @@
   }
 
   @Test
+  public void commentsArePortedWhenAllEditsAreDueToRebase() throws Exception {
+    String fileName = "f.txt";
+    String baseContent =
+        IntStream.rangeClosed(1, 50)
+            .mapToObj(number -> String.format("Line %d\n", number))
+            .collect(joining());
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    ObjectId baseCommit = addCommit(headCommit, fileName, baseContent);
+
+    // Create a change on top of baseCommit, modify line 1, then add comment on line 10.
+    PushOneCommit.Result r = createEmptyChange();
+    Change.Id changeId = r.getChange().getId();
+    addModifiedPatchSet(
+        changeId.toString(), fileName, baseContent.replace("Line 1\n", "Line one\n"));
+    PatchSet.Id ps2Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    newComment(ps2Id).message("Line comment").onLine(10).ofFile(fileName).create();
+
+    // Add a commit on top of baseCommit. Delete line 4. Rebase the change on top of this commit.
+    ObjectId newBase = addCommit(baseCommit, fileName, baseContent.replace("Line 4\n", ""));
+    rebaseChangeOn(changeId.toString(), newBase);
+    PatchSet.Id ps3Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(ps3Id));
+    assertThat(portedComments).hasSize(1);
+    int portedLine = portedComments.get(0).line;
+    BinaryResult fileContent = gApi.changes().id(changeId.get()).current().file(fileName).content();
+    List<String> lines = Splitter.on("\n").splitToList(fileContent.asString());
+    // Comment has shifted to L9 instead of L10 because of the deletion of line 4.
+    assertThat(portedLine).isEqualTo(9);
+    assertThat(lines.get(portedLine - 1)).isEqualTo("Line 10");
+  }
+
+  @Test
   public void resolvedAndUnresolvedDraftCommentsArePorted() throws Exception {
     Account.Id accountId = accountOps.newAccount().create();
     // Set up change and patchsets.
@@ -1803,7 +1845,7 @@
   }
 
   @Test
-  public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
+  public void deletedCommentContentIsNotPorted() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId = changeOps.newChange().create();
     PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
@@ -1817,9 +1859,9 @@
         .revision(patchsetId1.get())
         .comment(commentUuid)
         .delete(new DeleteCommentInput());
-    CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
 
-    assertThat(portedComment).message().doesNotContain("Confidential content");
+    assertThatList(portedComments).isEmpty();
   }
 
   @Test
@@ -1897,9 +1939,7 @@
    */
   private static ImmutableList<CommentInfo> flatten(
       Map<String, List<CommentInfo>> commentsPerFile) {
-    return commentsPerFile.values().stream()
-        .flatMap(Collection::stream)
-        .collect((toImmutableList()));
+    return commentsPerFile.values().stream().flatMap(Collection::stream).collect(toImmutableList());
   }
 
   // Unfortunately, we don't get an absolutely helpful error message when using this correspondence
@@ -1909,4 +1949,35 @@
   private static Correspondence<CommentInfo, String> hasUuid() {
     return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
   }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+
+  private void rebaseChangeOn(String changeId, ObjectId newParent) throws Exception {
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = newParent.getName();
+    gApi.changes().id(changeId).current().rebase(rebaseInput);
+  }
+
+  private void addModifiedPatchSet(String changeId, String filePath, String content)
+      throws Exception {
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(content));
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, String fileName, String fileContent)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Adjust files of repo",
+            ImmutableMap.of(fileName, fileContent));
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index e645ecf..efd3cea 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -52,12 +52,13 @@
 import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 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.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -149,7 +150,7 @@
 
     Map<String, FileDiffOutput> modifiedFiles =
         diffOperations.listModifiedFilesAgainstParent(
-            project, result.getCommit(), /* parentNum= */ 0);
+            project, result.getCommit(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
     assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
     assertThat(
@@ -3004,6 +3005,9 @@
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
@@ -3022,16 +3026,18 @@
           abbreviateName(parentCommit, 8, testRepo.getRevWalk().getObjectReader());
       headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
 
-      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
       PersonIdent author = c.getAuthorIdent();
-      dtfmt.setTimeZone(author.getTimeZone());
+      DateTimeFormatter fmt =
+          DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z")
+              .withLocale(Locale.US)
+              .withZone(author.getTimeZone().toZoneId());
       headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + dtfmt.format(author.getWhen().getTime()));
+      headers.add("AuthorDate: " + fmt.format(author.getWhen().toInstant()));
 
       PersonIdent committer = c.getCommitterIdent();
-      dtfmt.setTimeZone(committer.getTimeZone());
+      fmt = fmt.withZone(committer.getTimeZone().toZoneId());
       headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + dtfmt.format(committer.getWhen().getTime()));
+      headers.add("CommitDate: " + fmt.format(committer.getWhen().toInstant()));
       headers.add("");
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index d3fe83f..4a7849f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -95,12 +95,15 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
-import java.sql.Timestamp;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
 import java.util.Collection;
@@ -685,6 +688,26 @@
   }
 
   @Test
+  public void cherryPickWithValidationOptions() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "Cherry Pick";
+    in.validationOptions = ImmutableMap.of("key", "value");
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(r.getChangeId()).current().cherryPickAsInfo(in);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void cherryPickToExistingChangeUpdatesCherryPickOf() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
@@ -1683,10 +1706,15 @@
     }
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
     assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
     assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
-    assertThat(gitPerson.date).isEqualTo(new Timestamp(expectedIdent.getWhen().getTime()));
+    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhen().getTime());
     assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
   }
 
@@ -2077,4 +2105,15 @@
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
     return Iterables.transform(r, a -> Account.id(a._accountId));
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 2253202..ba1e1a7 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1427,6 +1427,34 @@
   }
 
   @Test
+  public void pushToNonVisibleBranchIsRejected() throws Exception {
+    String master = "refs/heads/master";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(master).group(REGISTERED_USERS))
+        .update();
+
+    testRepo.branch("HEAD").commit().message("New Commit 1").insertChangeId().create();
+    // Since the branch is not visible to the caller, the command tries to create the ref resulting
+    // in the command being rejected because the ref already exists.
+    assertPushRejected(
+        pushHead(testRepo, master),
+        master,
+        "Cannot create ref 'refs/heads/master' because it already exists.");
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(master).group(REGISTERED_USERS))
+        .update();
+
+    testRepo.branch("HEAD").commit().message("New Commit 2").insertChangeId().create();
+    assertPushOk(pushHead(testRepo, master), master);
+  }
+
+  @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 31292d5..0cdac5a 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Address;
@@ -65,6 +66,7 @@
   }
 
   @Test
+  @UseLocalDisk
   public void submitOnPush() throws Exception {
     projectOperations
         .project(project)
@@ -76,6 +78,8 @@
     r.assertChange(Change.Status.MERGED, null, admin);
     assertSubmitApproval(r.getPatchSetId());
     assertCommit(project, "refs/heads/master");
+    assertThat(gApi.projects().name(project.get()).branch("master").reflog().get(0).comment)
+        .isEqualTo("forced-merge");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index 13311e3..db73f3f 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -10,7 +10,7 @@
         ":submodule_util",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server/git/receive/testing",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 ) for f in glob(["*IT.java"])]
 
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index a1974b9..f94aa12 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -406,7 +406,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection s : cfg.getAccessSections()) {
       if (s.getName().startsWith("refs/heads/")
           || s.getName().startsWith("refs/for/")
           || s.getName().equals("refs/*")) {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 194f5f9..f58f81c 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
@@ -82,6 +83,7 @@
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   private AccountGroup.UUID admins;
   private AccountGroup.UUID nonInteractiveUsers;
@@ -125,7 +127,7 @@
     // Remove read permissions for all users besides admin.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
 
-      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
         u.getConfig()
             .upsertAccessSection(
                 sec.getName(),
@@ -142,7 +144,7 @@
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
-      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
         u.getConfig()
             .upsertAccessSection(
                 sec.getName(),
@@ -1395,14 +1397,15 @@
   public void fetchSingleChangeWithoutIndexAccess() throws Exception {
     PushOneCommit.Result change = createChange();
     String patchSetRef = change.getPatchSetId().toRefName();
-    try (AutoCloseable ignored = disableChangeIndex();
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
         Repository repo = repoManager.openRepository(project)) {
-      Collection<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
-      Collection<Ref> filteredRefs =
-          permissionBackend
-              .user(user(admin))
-              .project(project)
-              .filter(singleRef, repo, RefFilterOptions.defaults());
+      ImmutableList<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
+      ImmutableList<Ref> filteredRefs =
+          ImmutableList.copyOf(
+              permissionBackend
+                  .user(user(admin))
+                  .project(project)
+                  .filter(singleRef, repo, RefFilterOptions.defaults()));
       assertThat(filteredRefs).isEqualTo(singleRef);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index 9c5afd2..3cf54f3 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -25,6 +27,7 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE_NONFASTFORWARD;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
@@ -79,6 +82,35 @@
   }
 
   @Test
+  public void infoMessagesAreReturnedOnPush() throws Exception {
+    String message1 = "for bar baz";
+    String message2 = "abc xyz";
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new RefOperationValidationListener() {
+                  @Override
+                  public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+                      throws ValidationException {
+                    return ImmutableList.of(
+                        new ValidationMessage(message1, ValidationMessage.Type.HINT),
+                        new ValidationMessage(message2, ValidationMessage.Type.HINT));
+                  }
+                })) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of());
+      assertPushOk(r, "refs/heads/new");
+      assertThat(r.getMessages()).contains(String.format("hint: %s\nhint: %s", message1, message2));
+    }
+  }
+
+  @Test
   public void rejectRefCreation() throws Exception {
     try (Registration registration = testValidator(CREATE)) {
       RestApiException expected =
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 1c8ca93..d2aab5b 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -53,6 +54,8 @@
   }
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private IndexOperations.Account accountIndexOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Test
@@ -747,12 +750,10 @@
   }
 
   private String getStatus(ChangeData cd) throws Exception {
-
-    try (AutoCloseable changeIndex = disableChangeIndex()) {
-      try (AutoCloseable accountIndex = disableAccountIndex()) {
-        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-        return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
-      }
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
+        AutoCloseable accountIndex = accountIndexOperations.disableReadsAndWrites()) {
+      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+      return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 8367f60..ef2ca95 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -35,8 +34,7 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayDeque;
-import java.util.Map;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -144,7 +142,6 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId =
         subRepo
@@ -156,25 +153,6 @@
             .getObjectId();
 
     expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
-
-    // As the submodules have changed commits, the superproject tree will be
-    // different, so we cannot directly compare the trees here, so make
-    // assumptions only about the changed branches:
-    assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
-    assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
-
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // each change is updated and the respective target branch is updated:
-      assertThat(preview).hasSize(5);
-    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
-      // Either the first is used first as is, then the second and third need
-      // rebasing, or those two stay as is and the first is rebased.
-      // add in 2 master branches, expect 3 or 4:
-      assertThat(preview.size()).isAnyOf(3, 4);
-    } else {
-      assertThat(preview).hasSize(2);
-    }
   }
 
   @Test
@@ -661,12 +639,6 @@
   }
 
   @Test
-  public void branchCircularSubscriptionPreview() throws Exception {
-    testBranchCircularSubscription(
-        changeId -> gApi.changes().id(changeId).current().submitPreview());
-  }
-
-  @Test
   public void projectCircularSubscriptionWholeTopic() throws Exception {
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
     allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
diff --git a/javatests/com/google/gerrit/acceptance/pgm/CopyApprovalsPgmIT.java b/javatests/com/google/gerrit/acceptance/pgm/CopyApprovalsPgmIT.java
deleted file mode 100644
index ea359bf..0000000
--- a/javatests/com/google/gerrit/acceptance/pgm/CopyApprovalsPgmIT.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// 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.pgm;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.StandaloneSiteTest;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.launcher.GerritLauncher;
-import org.junit.Test;
-
-@UseLocalDisk
-public class CopyApprovalsPgmIT extends StandaloneSiteTest {
-  @Test
-  public void programFinishesNormally() throws Exception {
-    // This test checks that we are able to set up the injector for the program and loop over
-    // changes. The actual migration logic is tested elsewhere.
-    Project.NameKey project = Project.nameKey("reindex-project-test");
-    try (ServerContext ctx = startServer()) {
-      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
-
-      gApi.projects().create(project.get());
-
-      ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
-      in.newBranch = true;
-      gApi.changes().create(in);
-    }
-
-    int exitCode =
-        GerritLauncher.mainImpl(
-            new String[] {
-              "CopyApprovals", "-d", sitePaths.site_path.toString(), "--show-stack-trace"
-            });
-    assertThat(exitCode).isEqualTo(0);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java b/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
index 9cdcb40..7d8794b 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.server.index.OnlineUpgradeListener;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.concurrent.CountDownLatch;
 
 class IndexUpgradeController implements OnlineUpgradeListener {
@@ -45,18 +43,18 @@
   private final CountDownLatch started;
   private final CountDownLatch finished;
 
-  private final List<UpgradeAttempt> startedAttempts;
-  private final List<UpgradeAttempt> succeededAttempts;
-  private final List<UpgradeAttempt> failedAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> startedAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> succeededAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> failedAttempts;
 
   IndexUpgradeController(int numExpected) {
     this.numExpected = numExpected;
     readyToStart = new CountDownLatch(1);
     started = new CountDownLatch(numExpected);
     finished = new CountDownLatch(numExpected);
-    startedAttempts = new ArrayList<>();
-    succeededAttempts = new ArrayList<>();
-    failedAttempts = new ArrayList<>();
+    startedAttempts = ImmutableList.builder();
+    succeededAttempts = ImmutableList.builder();
+    failedAttempts = ImmutableList.builder();
   }
 
   Module module() {
@@ -93,7 +91,7 @@
     finish(UpgradeAttempt.create(name, oldVersion, newVersion), failedAttempts);
   }
 
-  private synchronized void finish(UpgradeAttempt a, List<UpgradeAttempt> out) {
+  private synchronized void finish(UpgradeAttempt a, ImmutableList.Builder<UpgradeAttempt> out) {
     checkState(readyToStart.getCount() == 0, "shouldn't be finishing upgrade before starting");
     checkState(
         finished.getCount() > 0, "already finished %s upgrades, can't finish %s", numExpected, a);
@@ -108,14 +106,14 @@
   }
 
   synchronized ImmutableList<UpgradeAttempt> getStartedAttempts() {
-    return ImmutableList.copyOf(startedAttempts);
+    return startedAttempts.build();
   }
 
   synchronized ImmutableList<UpgradeAttempt> getSucceededAttempts() {
-    return ImmutableList.copyOf(succeededAttempts);
+    return succeededAttempts.build();
   }
 
   synchronized ImmutableList<UpgradeAttempt> getFailedAttempts() {
-    return ImmutableList.copyOf(failedAttempts);
+    return failedAttempts.build();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
index ed5e559..c868d0b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
 import org.apache.http.message.BasicHeader;
@@ -194,6 +195,7 @@
 
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void abortIfServerDeadlineExceeded() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded\n\ntimeout=1ms");
@@ -203,6 +205,7 @@
   @GerritConfig(name = "deadline.foo.timeout", value = "1ms")
   @GerritConfig(name = "deadline.bar.timeout", value = "100ms")
   public void stricterDeadlineTakesPrecedence() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -213,6 +216,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestType", value = "REST")
   public void abortIfServerDeadlineExceeded_requestType() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -223,6 +227,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestUriPattern", value = "/projects/.*")
   public void abortIfServerDeadlineExceeded_requestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -235,6 +240,7 @@
       name = "deadline.default.excludedRequestUriPattern",
       value = "/projects/non-matching")
   public void abortIfServerDeadlineExceeded_excludedRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -249,6 +255,7 @@
       value = "/projects/non-matching")
   public void abortIfServerDeadlineExceeded_requestUriPatternAndExcludedRequestUriPattern()
       throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -259,6 +266,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.projectPattern", value = ".*new.*")
   public void abortIfServerDeadlineExceeded_projectPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -269,6 +277,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.account", value = "1000000")
   public void abortIfServerDeadlineExceeded_account() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -279,6 +288,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestType", value = "SSH")
   public void nonMatchingServerDeadlineIsIgnored_requestType() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -287,6 +297,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestUriPattern", value = "/changes/.*")
   public void nonMatchingServerDeadlineIsIgnored_requestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -295,6 +306,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "/projects/.*")
   public void nonMatchingServerDeadlineIsIgnored_excludedRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -305,6 +317,7 @@
   @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "/projects/.*new")
   public void nonMatchingServerDeadlineIsIgnored_requestUriPatternAndExcludedRequestUriPattern()
       throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -313,6 +326,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.projectPattern", value = ".*foo.*")
   public void nonMatchingServerDeadlineIsIgnored_projectPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -321,6 +335,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.account", value = "999")
   public void nonMatchingServerDeadlineIsIgnored_account() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -329,6 +344,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.isAdvisory", value = "true")
   public void advisoryServerDeadlineIsIgnored() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -338,6 +354,7 @@
   @GerritConfig(name = "deadline.test.isAdvisory", value = "true")
   @GerritConfig(name = "deadline.default.timeout", value = "2ms")
   public void nonAdvisoryDeadlineIsAppliedIfStricterAdvisoryDeadlineExists() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(4));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -347,6 +364,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1")
   public void invalidServerDeadlineIsIgnored_missingTimeUnit() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -354,6 +372,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1x")
   public void invalidServerDeadlineIsIgnored_invalidTimeUnit() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -369,6 +388,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestType", value = "INVALID")
   public void invalidServerDeadlineIsIgnored_invalidRequestType() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -377,6 +397,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestUriPattern", value = "][")
   public void invalidServerDeadlineIsIgnored_invalidRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -385,6 +406,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "][")
   public void invalidServerDeadlineIsIgnored_invalidExcludedRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -393,6 +415,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.projectPattern", value = "][")
   public void invalidServerDeadlineIsIgnored_invalidProjectPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -401,6 +424,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.account", value = "invalid")
   public void invalidServerDeadlineIsIgnored_invalidAccount() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -416,6 +440,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "0ms")
   @GerritConfig(name = "deadline.default.requestType", value = "REST")
   public void deadlineConfigWithZeroTimeoutIsIgnored() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -449,6 +474,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientProvidedDeadlineOverridesServerDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response =
         adminRestSession.putWithHeaders(
             "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "2ms"));
@@ -460,6 +486,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientCanDisableDeadlineBySettingZeroAsDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response =
         adminRestSession.putWithHeaders(
             "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "0"));
@@ -574,6 +601,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void abortPushIfServerDeadlineExceeded() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertErrorStatus("Server Deadline Exceeded (default.timeout=1ms)");
@@ -582,6 +610,7 @@
   @Test
   @GerritConfig(name = "receive.timeout", value = "1ms")
   public void abortPushIfTimeoutExceeded() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertErrorStatus("Server Deadline Exceeded (receive.timeout=1ms)");
@@ -591,6 +620,7 @@
   @GerritConfig(name = "receive.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.timeout", value = "10s")
   public void receiveTimeoutTakesPrecedence() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertErrorStatus("Server Deadline Exceeded (receive.timeout=1ms)");
@@ -649,6 +679,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientProvidedDeadlineOnPushOverridesServerDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     List<String> pushOptions = new ArrayList<>();
     pushOptions.add("deadline=2ms");
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
@@ -660,6 +691,7 @@
   @Test
   @GerritConfig(name = "receive.timeout", value = "1ms")
   public void clientProvidedDeadlineOnPushDoesntOverrideServerTimeout() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     List<String> pushOptions = new ArrayList<>();
     pushOptions.add("deadline=10m");
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
@@ -671,6 +703,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientCanDisableDeadlineOnPushBySettingZeroAsDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     List<String> pushOptions = new ArrayList<>();
     pushOptions.add("deadline=0");
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index be4fde0..e1e5b85 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -57,7 +57,7 @@
       RestResponse r = userRestSession.get("/accounts/self/capabilities");
       r.assertOK();
       CapabilityInfo info =
-          (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
+          new Gson().fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
       for (String c : GlobalCapability.getAllNames()) {
         if (ADMINISTRATE_SERVER.equals(c)) {
           assertThat(info.administrateServer).isFalse();
@@ -87,7 +87,7 @@
     RestResponse r = adminRestSession.get("/accounts/self/capabilities");
     r.assertOK();
     CapabilityInfo info =
-        (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
+        new Gson().fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
     for (String c : GlobalCapability.getAllNames()) {
       if (BATCH_CHANGES_LIMIT.equals(c)) {
         // It does not have default value for any user as it can override the
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 1e89a85..39a32af 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
@@ -66,7 +65,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.testing.ConfigSuite;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -83,7 +81,6 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
@@ -106,20 +103,6 @@
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
   @Inject private ExternalIdFactory externalIdFactory;
 
-  @ConfigSuite.Default
-  public static Config partialCacheReloadingEnabled() {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", true);
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config partialCacheReloadingDisabled() {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", false);
-    return cfg;
-  }
-
   @Test
   public void getExternalIds() throws Exception {
     Collection<ExternalId> expectedIds = getAccountState(user.id()).externalIds();
@@ -732,38 +715,7 @@
   }
 
   @Test
-  public void checkNoReloadAfterUpdate() throws Exception {
-    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id()));
-    try (AutoCloseable ctx = createFailOnLoadContext()) {
-      // insert external ID
-      ExternalId extId = externalIdFactory.create("foo", "bar", admin.id());
-      insertExtId(extId);
-      expectedExtIds.add(extId);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-
-      // update external ID
-      expectedExtIds.remove(extId);
-      ExternalId extId2 =
-          externalIdFactory.createWithEmail("foo", "bar", admin.id(), "foo.bar@example.com");
-      accountsUpdateProvider
-          .get()
-          .update("Update External ID", admin.id(), u -> u.updateExternalId(extId2));
-      expectedExtIds.add(extId2);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-
-      // delete external ID
-      accountsUpdateProvider
-          .get()
-          .update("Delete External ID", admin.id(), u -> u.deleteExternalId(extId));
-      expectedExtIds.remove(extId2);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-    }
-  }
-
-  @Test
   public void byAccountFailIfReadingExternalIdsFails() throws Exception {
-    assume().that(isPartialCacheReloadingEnabled()).isFalse();
-
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
@@ -774,8 +726,6 @@
 
   @Test
   public void byEmailFailIfReadingExternalIdsFails() throws Exception {
-    assume().that(isPartialCacheReloadingEnabled()).isFalse();
-
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
@@ -1033,10 +983,6 @@
     extIdNotes.commit(md);
   }
 
-  private boolean isPartialCacheReloadingEnabled() {
-    return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
-  }
-
   private void insertExtId(ExternalId extId) throws Exception {
     accountsUpdateProvider
         .get()
@@ -1059,7 +1005,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
       ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers, repo, externalIdFactory, IS_USER_NAME_CASE_INSENSITIVE_MIGRATION_MODE);
       extIdNotes.insert(extId);
       try (MetaDataUpdate metaDataUpdate =
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index a1e9bf1..c89e11a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -59,7 +59,7 @@
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertAccountInfo(admin, info);
     Account account = getAccount(admin.id());
-    assertThat(info.registeredOn).isEqualTo(account.registeredOn());
+    assertThat(info.registeredOn.getTime()).isEqualTo(account.registeredOn().toEpochMilli());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 79484ca..f3b13d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -145,7 +145,6 @@
           RestCall.get("/changes/%s/revisions/%s/related"),
           RestCall.get("/changes/%s/revisions/%s/review"),
           RestCall.post("/changes/%s/revisions/%s/review"),
-          RestCall.get("/changes/%s/revisions/%s/preview_submit"),
           RestCall.post("/changes/%s/revisions/%s/submit"),
           RestCall.get("/changes/%s/revisions/%s/submit_type"),
           RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index d967f48..de14d00 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -81,7 +81,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -104,10 +103,8 @@
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffFormatter;
@@ -148,162 +145,25 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
   public void submitSingleChange() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
 
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // The change is updated as well:
-      assertThat(actual).hasSize(2);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
     submit(change.getChangeId());
-    assertTrees(project, actual);
-  }
-
-  @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-
-    try (BinaryResult request =
-        gApi.changes().id(change4.getChangeId()).current().submitPreview()) {
-      assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
-      submit(change4.getChangeId());
-    } catch (RestApiException e) {
-      switch (getSubmitType()) {
-        case FAST_FORWARD_ONLY:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.");
-          break;
-        case REBASE_IF_NECESSARY:
-        case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().commitId().name();
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Cannot rebase "
-                      + change2hash
-                      + ": The change could "
-                      + "not be rebased due to a conflict during merge.\n\n"
-                      + "merge conflict(s):\n"
-                      + "a.txt");
-          break;
-        case MERGE_ALWAYS:
-        case MERGE_IF_NECESSARY:
-        case INHERIT:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.");
-          break;
-        case CHERRY_PICK:
-        default:
-          assertWithMessage("Should not reach here.").fail();
-          break;
-      }
-
-      RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
-      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-      assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-    }
-  }
-
-  @Test
-  public void submitMultipleChangesPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
-    Map<String, Map<String, Integer>> expected = new HashMap<>();
-    expected.put(project.get(), new HashMap<>());
-    expected.get(project.get()).put("refs/heads/master", 3);
-
-    assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      // CherryPick ignores dependencies, thus only change and destination
-      // branch refs are modified.
-      assertThat(actual).hasSize(2);
-    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
-      // destination branch will be modified.
-      assertThat(actual).hasSize(4);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
-    // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-    assertThat(headAfterSubmit).isEqualTo(initialHead);
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-
-    // now check we actually have the same content:
-    approve(change2.getChangeId());
-    submit(change4.getChangeId());
-    assertTrees(project, actual);
+    headAfterSubmit = projectOperations.project(project).getHead("master");
+    assertThat(headAfterSubmit).isNotEqualTo(initialHead);
   }
 
   /**
@@ -1238,14 +1098,11 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
@@ -1259,20 +1116,17 @@
     change.assertOkStatus();
     assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
 
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
-          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.nowTs())) {
+          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.now())) {
         bu.addOp(
             change.getChange().getId(),
             new BatchUpdateOp() {
@@ -1395,8 +1249,8 @@
     submit(r.getChangeId());
     assertThat(r.getChange().getMergedOn()).isPresent();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.updated);
-    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.submitted);
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.getUpdated());
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.getSubmitted());
   }
 
   @Override
@@ -1506,8 +1360,12 @@
     assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
-    assertThat(commit.getAuthorIdent().getWhen()).isEqualTo(commit.getCommitterIdent().getWhen());
+    assertThat(commit.getAuthorIdent().getWhen().getTime())
+        .isEqualTo(commit.getCommitterIdent().getWhen().getTime());
     assertThat(commit.getAuthorIdent().getTimeZone())
         .isEqualTo(commit.getCommitterIdent().getTimeZone());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index e35f758..fbcc5fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -104,7 +104,8 @@
 
   @Test
   public void revisionActionsTwoChangesInTopic() throws Exception {
-    String changeId = createChangeWithTopic().getChangeId();
+    PushOneCommit.Result change1 = createChangeWithTopic();
+    String changeId = change1.getChangeId();
     approve(changeId);
     PushOneCommit.Result change2 = createChangeWithTopic();
     int legacyId2 = change2.getChange().getId().get();
@@ -116,7 +117,15 @@
       assertThat(info.enabled).isNull();
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).matches("Change " + legacyId2 + " is not ready: needs Code-Review");
+      assertThat(info.title)
+          .startsWith(
+              "Change "
+                  + change1.getChange().getId()
+                  + " must be submitted with change "
+                  + legacyId2
+                  + " but "
+                  + legacyId2
+                  + " is not ready: submit requirement 'Code-Review' is unsatisfied.");
     } else {
       noSubmitWholeTopicAssertions(actions, 1);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 576b5512..e013267 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
@@ -261,6 +262,12 @@
             fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed");
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
+    // The removal also shows up in AttentionSetInfo.
+    AttentionSetInfo attentionSetInfo =
+        Iterables.getOnlyElement(change(r).get().removedFromAttentionSet.values());
+    assertThat(attentionSetInfo.reason).isEqualTo("removed");
+    assertThat(attentionSetInfo.account).isEqualTo(getAccountInfo(user.id()));
+
     // Second removal is ignored.
     fakeClock.advance(Duration.ofSeconds(42));
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed again"));
@@ -1927,6 +1934,30 @@
   }
 
   @Test
+  public void deleteVotesDoesNotAffectAttentionSetWhenIgnoreAutomaticRulesIsSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    DeleteVoteInput deleteVoteInput = new DeleteVoteInput();
+    deleteVoteInput.label = LabelId.CODE_REVIEW;
+
+    // set this to true to not change the attention set.
+    deleteVoteInput.ignoreAutomaticAttentionSetRules = true;
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.id().toString())
+        .deleteVote(deleteVoteInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
   public void deleteVotesOfOthersAddThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -2031,7 +2062,7 @@
     comment.side = Side.REVISION;
     comment.path = Patch.COMMIT_MSG;
     comment.message = "comment";
-    comment.updated = TimeUtil.nowTs();
+    comment.setUpdated(TimeUtil.now());
     comment.inReplyTo = id;
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 88e5f10..70a3cf2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -65,9 +65,9 @@
       gApi.changes().id(r.getChangeId()).addReviewer(input);
 
       ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
-      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      assertThat(info.reviewers).containsExactly(state, ImmutableList.of(acc));
       // All reviewers added by email should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+      assertThat(info.removableReviewers).containsExactly(acc);
     }
   }
 
@@ -92,7 +92,7 @@
       ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
       assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
       // All reviewers (both by id and by email) should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
+      assertThat(info.removableReviewers).containsExactly(byId, byEmail);
     }
   }
 
@@ -368,6 +368,6 @@
   }
 
   private static String toRfcAddressString(AccountInfo info) {
-    return (Address.create(info.name, info.email)).toString();
+    return Address.create(info.name, info.email).toString();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index b0a14cf..dbebbf9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -27,6 +27,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -593,7 +594,7 @@
 
       PersonIdent expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.created, serverIdent.get());
+              getAccount(admin.id()).id(), c.created.toInstant(), serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
@@ -1085,7 +1086,7 @@
     ChangeInfo out = gApi.changes().create(in).get();
     assertThat(out.project).isEqualTo(in.project);
     assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
-    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
+    assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
     if (in.isPrivate) {
@@ -1109,7 +1110,7 @@
     ChangeInfo out = gApi.changes().createAsInfo(in);
     assertThat(out.project).isEqualTo(in.project);
     assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
-    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
+    assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
     if (in.isPrivate) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
index 29dd227..26e37f4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -60,6 +60,21 @@
   }
 
   @Test
+  public void metaDiffSubmitReq() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get();
+    chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+    ChangeInfo newInfo = chApi.get();
+
+    ChangeInfoDifference difference =
+        chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId, ListChangesOption.SUBMIT_REQUIREMENTS);
+
+    assertThat(difference.added().submitRequirements).isNull();
+    assertThat(difference.removed().submitRequirements).isNull();
+  }
+
+  @Test
   public void metaDiffReturnsSuccessful() throws Exception {
     PushOneCommit.Result ch = createChange();
     ChangeInfo info = gApi.changes().id(ch.getChangeId()).get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 58e48e9..3be49df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -99,7 +99,7 @@
         "Failed to submit 2 changes due to the following problems:\n"
             + "Change "
             + id1
-            + ": needs Code-Review");
+            + ": submit requirement 'Code-Review' is unsatisfied.");
 
     RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 157c93c..c4f8f2c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -38,22 +38,10 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
-import java.io.File;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
-import java.util.zip.GZIPInputStream;
-import org.apache.commons.compress.archivers.ArchiveStreamFactory;
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
@@ -184,8 +172,6 @@
     approve(change2b.getChangeId());
     approve(change3.getChangeId());
 
-    // get a preview before submitting:
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
     RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -197,23 +183,9 @@
     if (isSubmitWholeTopicEnabled()) {
       assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-
-      // check that the preview matched what happened:
-      assertThat(preview).hasSize(3);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
-      assertTrees(p1, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
-      assertTrees(p2, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
-      assertTrees(p3, preview);
     } else {
       assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
-      assertThat(preview).hasSize(1);
-      assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
@@ -281,13 +253,6 @@
               + "merged due to a path conflict. Please rebase the change locally "
               + "and upload the rebased commit for review.";
 
-      // Get a preview before submitting:
-      RestApiException thrown =
-          assertThrows(
-              RestApiException.class,
-              () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
-      assertThat(thrown.getMessage()).isEqualTo(msg);
-
       submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
@@ -756,34 +721,4 @@
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
-
-  @Test
-  public void testPreviewSubmitTgz() throws Throwable {
-    Project.NameKey p1 = projectOperations.newProject().create();
-
-    TestRepository<?> repo1 = cloneProject(p1);
-    PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
-    approve(change1.getChangeId());
-
-    // get a preview before submitting:
-    File tempfile;
-    try (BinaryResult request =
-        gApi.changes().id(change1.getChangeId()).current().submitPreview("tgz")) {
-      assertThat(request.getContentType()).isEqualTo("application/x-gzip");
-      tempfile = File.createTempFile("test", null);
-      request.writeTo(Files.newOutputStream(tempfile.toPath()));
-    }
-
-    InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
-
-    List<String> untarredFiles = new ArrayList<>();
-    try (TarArchiveInputStream tarInputStream =
-        (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
-      TarArchiveEntry entry;
-      while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
-        untarredFiles.add(entry.getName());
-      }
-    }
-    assertThat(untarredFiles).containsExactly(p1.get() + ".git");
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index aa93815..2eade27 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project.NameKey;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -136,8 +136,8 @@
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (Registration registration =
         extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
-      InternalServerWithUserMessageException thrown =
-          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
+      MergeUpdateException thrown =
+          assertThrows(MergeUpdateException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause).hasMessageThat().isEqualTo("boom");
@@ -153,8 +153,8 @@
             .newRegistration()
             .add(modifier1, "modifier-1")
             .add(modifier2, "modifier-2")) {
-      InternalServerWithUserMessageException thrown =
-          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
+      MergeUpdateException thrown =
+          assertThrows(MergeUpdateException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index a63d60a..0a9a098 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -306,7 +306,10 @@
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           PermissionBackendException {
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 97288a8..8131352 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -192,6 +192,9 @@
 
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
+
+    // submit requirement columns in dashboard
+    assertThat(i.submitRequirementDashboardColumns).isEmpty();
   }
 
   @Test
@@ -202,6 +205,15 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "dashboard.submitRequirementColumns",
+      values = {"Code-Review", "Verified"})
+  public void serverConfigWithMultipleSubmitRequirementColumn() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.submitRequirementDashboardColumns).containsExactly("Code-Review", "Verified");
+  }
+
+  @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeabilityComputationBehavior_neverCompute() throws Exception {
     ServerInfo i = gApi.config().server().getInfo();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 531357a..793f256 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.ANNOTATED;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.LIGHTWEIGHT;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -29,6 +30,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.testing.ConfigSuite;
@@ -191,33 +193,57 @@
     pushTagDeletion(tagName, Status.OK);
   }
 
+  @Test
+  public void pushToNonVisibleTagIsRejected() throws Exception {
+    allowTagCreation();
+    allowPushOnRefsTags();
+
+    String tagName = pushTagForExistingCommit(Status.OK);
+
+    removeReadFromRefsTags();
+    removeReadFromRefsHeads();
+
+    pushTag(
+        tagName,
+        /* newCommit= */ true,
+        /* force= */ false,
+        Status.REJECTED_OTHER_REASON,
+        /* expectedMessage= */ String.format(
+            "Cannot create ref '%s' because it already exists.", tagRef(tagName)));
+  }
+
   private String pushTagForExistingCommit(Status expectedStatus) throws Exception {
-    return pushTag(null, false, false, expectedStatus);
+    return pushTag(null, false, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private String pushTagForNewCommit(Status expectedStatus) throws Exception {
-    return pushTag(null, true, false, expectedStatus);
+    return pushTag(null, true, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void fastForwardTagToExistingCommit(String tagName, Status expectedStatus)
       throws Exception {
-    pushTag(tagName, false, false, expectedStatus);
+    pushTag(tagName, false, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void fastForwardTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagName, true, false, expectedStatus);
+    pushTag(tagName, true, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void forceUpdateTagToExistingCommit(String tagName, Status expectedStatus)
       throws Exception {
-    pushTag(tagName, false, true, expectedStatus);
+    pushTag(tagName, false, true, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void forceUpdateTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagName, true, true, expectedStatus);
+    pushTag(tagName, true, true, expectedStatus, /* expectedMessage= */ null);
   }
 
-  private String pushTag(String tagName, boolean newCommit, boolean force, Status expectedStatus)
+  private String pushTag(
+      String tagName,
+      boolean newCommit,
+      boolean force,
+      Status expectedStatus,
+      @Nullable String expectedMessage)
       throws Exception {
     if (force) {
       testRepo.reset(initialHead);
@@ -256,6 +282,9 @@
             : GitUtil.pushTag(testRepo, tagName, !createTag);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
     assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
+    if (expectedMessage != null) {
+      assertWithMessage(tagType.name()).that(refUpdate.getMessage()).isEqualTo(expectedMessage);
+    }
     return tagName;
   }
 
@@ -352,6 +381,22 @@
         .update();
   }
 
+  private void removeReadFromRefsTags() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  private void removeReadFromRefsHeads() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+  }
+
   private void commit(PersonIdent ident, String subject) throws Exception {
     commitBuilder().ident(ident).message(subject + " (" + System.nanoTime() + ")").create();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index edcb1f9..1fcc69a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -10,7 +10,7 @@
         ":project",
         ":push_tag_util",
         ":refassert",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 ) for f in glob(["*IT.java"])]
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 33a7dc5..df899ce 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -16,6 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
@@ -24,8 +27,10 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
@@ -58,6 +63,7 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -415,6 +421,87 @@
   }
 
   @Test
+  public void createBranchViaRestApiFailsIfCommitIsInvalid() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "new";
+
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    testRefOperationValidationListener.doReject = true;
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      ResourceConflictException ex =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.projects().name(project.get()).branch(input.ref).create(input));
+      assertThat(ex)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Validation for creation of ref 'refs/heads/new' in project %s failed:\n%s",
+                  project, TestRefOperationValidationListener.FAILURE_MESSAGE));
+    }
+  }
+
+  @Test
+  public void createBranchViaRestApiWithValidationOptions() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "new";
+    input.validationOptions = ImmutableMap.of("key", "value");
+
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      gApi.projects().name(project.get()).branch(input.ref).create(input);
+      assertThat(testRefOperationValidationListener.refReceivedEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void createBranchViaPushFailsIfCommitIsInvalid() throws Exception {
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    testRefOperationValidationListener.doReject = true;
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of());
+      assertPushRejected(
+          r,
+          "refs/heads/new",
+          String.format(
+              "Validation for creation of ref 'refs/heads/new' in project %s failed:\n%s",
+              project, TestRefOperationValidationListener.FAILURE_MESSAGE));
+    }
+  }
+
+  @Test
+  public void createBranchViaPushWithValidationOptions() throws Exception {
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of("key=value"));
+      assertPushOk(r, "refs/heads/new");
+      assertThat(testRefOperationValidationListener.refReceivedEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void createBranchRevisionVisibility() throws Exception {
     AccountGroup.UUID privilegedGroupUuid =
         groupOperations.newGroup().name(name("privilegedGroup")).create();
@@ -502,4 +589,24 @@
       assertThat(thrown).hasMessageThat().contains(errMsg);
     }
   }
+
+  private static class TestRefOperationValidationListener
+      implements RefOperationValidationListener {
+    static final String FAILURE_MESSAGE = "failure from test";
+
+    public boolean doReject;
+    public RefReceivedEvent refReceivedEvent;
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refReceivedEvent)
+        throws ValidationException {
+      this.refReceivedEvent = refReceivedEvent;
+
+      if (doReject) {
+        throw new ValidationException(FAILURE_MESSAGE);
+      }
+
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index dfe69f9..755c2e1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -224,6 +224,19 @@
   }
 
   @Test
+  public void createWithNameAndDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.description = "Foo label description";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.name).isEqualTo("Foo");
+    assertThat(createdLabel.description).isEqualTo("Foo label description");
+  }
+
+  @Test
   public void createWithNameAndValuesOnly() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index ce92536..d8b0cb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -37,6 +37,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -65,10 +67,10 @@
   }
 
   @Test
-  public void deleteBranchByProjectOwner() throws Exception {
+  public void deleteBranchByProjectOwner_Forbidden() throws Exception {
     grantOwner();
     requestScopeOperations.setApiUser(user.id());
-    assertDeleteSucceeds(testBranch);
+    assertDeleteForbidden(testBranch);
   }
 
   @Test
@@ -78,14 +80,6 @@
   }
 
   @Test
-  public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockForcePush();
-    requestScopeOperations.setApiUser(user.id());
-    assertDeleteForbidden(testBranch);
-  }
-
-  @Test
   public void deleteBranchByUserWithForcePushPermission() throws Exception {
     grantForcePush();
     requestScopeOperations.setApiUser(user.id());
@@ -192,6 +186,26 @@
     assertThat(thrown).hasMessageThat().contains("not allowed to delete HEAD");
   }
 
+  @Test
+  public void deleteRefsForBranch() throws Exception {
+    BranchNameKey refsForBranch = BranchNameKey.create(project, "refs/for/master");
+
+    // Creating a branch under refs/for/ is not allowed through the API, hence create it directly in
+    // the remote repo.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      repo.branch(refsForBranch.branch()).commit().message("Initial empty commit").create();
+    }
+
+    assertThat(branch(refsForBranch).get().canDelete).isTrue();
+    String branchRev = branch(refsForBranch).get().revision;
+
+    branch(refsForBranch).delete();
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), refsForBranch.branch(), branchRev, null);
+    assertThrows(ResourceNotFoundException.class, () -> branch(refsForBranch).get());
+  }
+
   private void blockForcePush() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 7e60395..8c0836d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -59,10 +59,10 @@
   }
 
   @Test
-  public void deleteTagByProjectOwner() throws Exception {
+  public void deleteTagByProjectOwner_Forbidden() throws Exception {
     grantOwner();
     requestScopeOperations.setApiUser(user.id());
-    assertDeleteSucceeds();
+    assertDeleteForbidden();
   }
 
   @Test
@@ -72,14 +72,6 @@
   }
 
   @Test
-  public void deleteTagByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockForcePush();
-    requestScopeOperations.setApiUser(user.id());
-    assertDeleteForbidden();
-  }
-
-  @Test
   public void deleteTagByUserWithForcePushPermission() throws Exception {
     grantForcePush();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index a7f3174..8dce9c3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -18,16 +18,17 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
-@NoHttpd
 public class FileBranchIT extends AbstractDaemonTest {
 
   private BranchNameKey branch;
@@ -47,6 +48,24 @@
   }
 
   @Test
+  public void getFileFromNonExistingBranch() throws Exception {
+    RestResponse response =
+        adminRestSession.get(
+            String.format("/projects/%s/branches/non-existing/files/path", project.get()));
+    response.assertNotFound();
+  }
+
+  @Test
+  public void getFileFromSymbolicRefPointingToAnUnbornBranch() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+    }
+    RestResponse response =
+        adminRestSession.get(String.format("/projects/%s/branches/HEAD/files/path", project.get()));
+    response.assertNotFound();
+  }
+
+  @Test
   public void getNonExistingFile() throws Exception {
     assertThrows(ResourceNotFoundException.class, () -> branch().file("does-not-exist"));
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index e9aa589..71ee90c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -40,8 +40,8 @@
     ImmutableMap<String, String> want =
         ImmutableMap.of(
             " 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");
     assertThat(l.values).isEqualTo(want);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 9e31026..40e5d50 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -35,9 +35,9 @@
             " 0",
             "No score",
             "-1",
-            "I would prefer this is not merged as is",
+            "I would prefer this is not submitted as is",
             "-2",
-            "This shall not be merged");
+            "This shall not be submitted");
     assertThat(codeReviewLabel.defaultValue).isEqualTo(0);
     assertThat(codeReviewLabel.branches).isNull();
     assertThat(codeReviewLabel.canOverride).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 3c8357b..b2ececc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.junit.Test;
 
 @NoHttpd
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index a397693..fbec664 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.inject.Inject;
 import java.util.List;
+import java.util.Optional;
 import org.junit.Test;
 
 @NoHttpd
@@ -100,6 +101,24 @@
   }
 
   @Test
+  public void labelWithDescription() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              "foo", labelType -> labelType.setDescription(Optional.of("foo label description")));
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.description).isEqualTo("foo label description");
+  }
+
+  @Test
   public void labelLimitedToBranches() throws Exception {
     configLabel(
         "foo", LabelFunction.NO_OP, ImmutableList.of("refs/heads/master", "^refs/heads/stable-.*"));
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 2e274d9..e69f781 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
@@ -143,7 +143,7 @@
   public void listProjectsToOutputStream() throws Exception {
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
-    List<String> testProjects = createProjects("zzz_testProject", numTestProjects);
+    ImmutableSet<String> testProjects = createProjects("zzz_testProject", numTestProjects);
     try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
 
       listProjects.setStart(numInitialProjects);
@@ -153,7 +153,7 @@
           Splitter.on("\n")
               .omitEmptyStrings()
               .splitToList(new String(displayOut.toByteArray(), UTF_8));
-      assertThat(lines).isEqualTo(testProjects);
+      assertThat(lines).isEqualTo(testProjects.asList());
     }
   }
 
@@ -173,8 +173,7 @@
 
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
-    Set<String> testProjects =
-        ImmutableSet.copyOf(createProjects("zzz_testProject", numTestProjects));
+    ImmutableSet<String> testProjects = createProjects("zzz_testProject", numTestProjects);
     try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
 
       listProjects.setStart(numInitialProjects);
@@ -191,11 +190,11 @@
     }
   }
 
-  private List<String> createProjects(String prefix, int numProjects) {
+  private ImmutableSet<String> createProjects(String prefix, int numProjects) {
     return IntStream.range(0, numProjects)
         .mapToObj(i -> projectOperations.newProject().name(prefix + i).create())
         .map(Project.NameKey::get)
-        .collect(toList());
+        .collect(toImmutableSet());
   }
 
   @Test
@@ -227,7 +226,7 @@
 
     assertThatNameList(gApi.projects().list().withRegex(".*some").get())
         .containsExactly(projectAwesome);
-    String r = ("lpwr-some-project$").replace(".", "\\.");
+    String r = "lpwr-some-project$".replace(".", "\\.");
     assertThatNameList(gApi.projects().list().withRegex(r).get()).containsExactly(someProject);
     assertThatNameList(gApi.projects().list().withRegex(".*").get())
         .containsExactly(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b4938c1..cfca936 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -94,6 +94,20 @@
   }
 
   @Test
+  public void updateDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.description = "Code review label description";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(updatedLabel.description).isEqualTo("Code review label description");
+
+    input.description = "";
+    updatedLabel = gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(updatedLabel.description).isNull();
+  }
+
+  @Test
   public void nameIsTrimmed() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.name = " Foo-Review ";
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index b1879f6..044da19 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -41,7 +41,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -222,14 +222,14 @@
     assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
     assertThat(result.canDelete).isTrue();
-    assertThat(result.created).isEqualTo(timestamp(r));
+    assertThat(result.created.toInstant()).isEqualTo(instant(r));
 
     input.ref = "refs/tags/v2.0";
     result = tag(input.ref).create(input).get();
     assertThat(result.ref).isEqualTo(input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
     assertThat(result.canDelete).isTrue();
-    assertThat(result.created).isEqualTo(timestamp(r));
+    assertThat(result.created.toInstant()).isEqualTo(instant(r));
 
     requestScopeOperations.setApiUser(user.id());
     result = tag(input.ref).get();
@@ -457,8 +457,11 @@
     return gApi.projects().name(project.get()).tag(tagname);
   }
 
-  private Timestamp timestamp(PushOneCommit.Result r) {
-    return new Timestamp(r.getCommit().getCommitterIdent().getWhen().getTime());
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
+  private Instant instant(PushOneCommit.Result r) {
+    return r.getCommit().getCommitterIdent().getWhen().toInstant();
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
@@ -477,7 +480,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection accessSection : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection accessSection : cfg.getAccessSections()) {
       cfg.upsertAccessSection(
           accessSection.getName(),
           updatedAccessSection -> {
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/BUILD b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
index 1d3fe65..9b16eb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
@@ -7,6 +7,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
index 7b0002c..91cf15a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
@@ -18,7 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.junit.Ignore;
 
 /** Data container for test REST requests. */
diff --git a/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
new file mode 100644
index 0000000..8405b25
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
@@ -0,0 +1,55 @@
+// 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.server.approval;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGeneratorImpl;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Instant;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link PatchSetApprovalUuidGeneratorImpl} - the default implementation of {@link
+ * PatchSetApprovalUuidGenerator}.
+ */
+@RunWith(JUnit4.class)
+public class PatchSetApprovalUuidTest {
+
+  @Test
+  public void sameInput_differentUuid() {
+    PatchSetApprovalUuidGeneratorImpl patchSetApprovalUuidGenerator =
+        new PatchSetApprovalUuidGeneratorImpl();
+    for (short value = -2; value <= 2; value++) {
+      PatchSet.Id patchSetId = PatchSet.id(Change.id(1), 1);
+      Account.Id accountId = Account.id(1);
+      String label = LabelId.CODE_REVIEW;
+      Instant granted = TimeUtil.now();
+      PatchSetApproval.UUID uuid1 =
+          patchSetApprovalUuidGenerator.get(patchSetId, accountId, label, value, granted);
+      PatchSetApproval.UUID uuid2 =
+          patchSetApprovalUuidGenerator.get(patchSetId, accountId, label, value, granted);
+      assertThat(uuid2).isNotEqualTo(uuid1);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 80cdad8..6d980c7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.acceptance.testsuite.change.TestHumanComment;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.HumanComment;
@@ -47,9 +48,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -66,7 +69,7 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -218,6 +221,30 @@
   }
 
   @Test
+  public void deletedCommentsAreResolved() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String commentMessage = "to be deleted";
+    CommentInput comment =
+        CommentsUtil.newComment(
+            COMMIT_MSG, Side.REVISION, /*line= */ 0, commentMessage, /*unresolved= */ true);
+    CommentsUtil.addComments(gApi, changeId, revId, comment);
+
+    Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
+    CommentInfo oldComment = Iterables.getOnlyElement(results.get(COMMIT_MSG));
+
+    DeleteCommentInput input = new DeleteCommentInput("reason");
+    gApi.changes().id(changeId).revision(revId).comment(oldComment.id).delete(input);
+    CommentInfo updatedComment =
+        Iterables.getOnlyElement(getPublishedComments(changeId, revId).get(COMMIT_MSG));
+
+    assertThat(updatedComment.message).doesNotContain(commentMessage);
+    assertThat(updatedComment.unresolved).isFalse();
+  }
+
+  @Test
   public void patchsetLevelCommentEmailNotification() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -640,7 +667,7 @@
   public void putDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
@@ -887,7 +914,7 @@
   public void deleteDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       DraftInput draft = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
@@ -903,7 +930,7 @@
 
   @Test
   public void insertCommentsWithHistoricTimestamp() throws Exception {
-    Timestamp timestamp = new Timestamp(0);
+    Instant timestamp = Instant.EPOCH;
     for (Integer line : lines) {
       String file = "file";
       String contents = "contents " + line;
@@ -912,11 +939,11 @@
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
 
       ReviewInput input = new ReviewInput();
       CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
-      comment.updated = timestamp;
+      comment.setUpdated(timestamp);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       ChangeResource changeRsrc =
@@ -1853,6 +1880,72 @@
     assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
   }
 
+  @Test
+  public void commentsOnRootCommitsAreIncludedInEmails() throws Exception {
+    // Create a change in a new branch, making the patch-set commit a root commit.
+    ChangeInfo changeInfo = createChangeInNewBranch("newBranch");
+    Change.Id changeId = Change.Id.tryParse(Integer.toString(changeInfo._number)).get();
+
+    // Add a file.
+    gApi.changes().id(changeId.get()).edit().modifyFile("f1.txt", RawInputUtil.create("content"));
+    gApi.changes().id(changeId.get()).edit().publish();
+    email.clear();
+
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = admin.email();
+    gApi.changes().id(changeId.get()).addReviewer(reviewerInput);
+    changeInfo = gApi.changes().id(changeId.get()).get();
+    assertThat(email.getMessages()).hasSize(1);
+    Message message = email.getMessages().get(0);
+    assertThat(message.body()).contains("f1.txt");
+    email.clear();
+
+    // Send a comment. Make sure the email that is sent includes the comment text.
+    CommentInput c1 =
+        CommentsUtil.newComment(
+            "f1.txt",
+            Side.REVISION,
+            /* line= */ 1,
+            /* message= */ "Comment text",
+            /* unresolved= */ false);
+    CommentsUtil.addComments(gApi, changeId.toString(), changeInfo.currentRevision, c1);
+    assertThat(email.getMessages()).hasSize(1);
+    Message commentMessage = email.getMessages().get(0);
+    assertThat(commentMessage.body())
+        .contains("Patch Set 2:\n" + "\n" + "(1 comment)\n" + "\n" + "File f1.txt:");
+    assertThat(commentMessage.body()).contains("PS2, Line 1: content\n" + "Comment text");
+  }
+
+  @Test
+  public void commentsOnDeletedFileIsIncludedInEmails() throws Exception {
+    // Create a change with a file.
+    createChange("subject", "f1.txt", "content");
+
+    // Stack a second change that deletes the file.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).edit().deleteFile("f1.txt");
+    gApi.changes().id(changeId).edit().publish();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+
+    // Add a comment on the deleted file on the parent side.
+    email.clear();
+    CommentInput commentInput =
+        CommentsUtil.newComment(
+            "f1.txt",
+            Side.PARENT,
+            /* line= */ 1,
+            /* message= */ "Comment text",
+            /* unresolved= */ false);
+    CommentsUtil.addComments(gApi, changeId, currentRevision, commentInput);
+
+    // Assert email contains the comment text.
+    assertThat(email.getMessages()).hasSize(1);
+    Message commentMessage = email.getMessages().get(0);
+    assertThat(commentMessage.body()).contains("Patch Set 2:\n\n(1 comment)\n\nFile f1.txt:");
+    assertThat(commentMessage.body()).contains("PS2, Line 1: content\nComment text");
+  }
+
   private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
     return getPublishedComments(changeId, revId).values().stream()
         .flatMap(List::stream)
@@ -2017,4 +2110,13 @@
     reviewInput.draftIdsToPublish = draftIdsToPublish;
     return reviewInput;
   }
+
+  private ChangeInfo createChangeInNewBranch(String branchName) throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = branchName;
+    in.newBranch = true;
+    in.subject = "New changes";
+    return gApi.changes().create(in).get();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 9d821b7..5b6da36 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -732,7 +732,7 @@
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.nowTs());
+    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.now());
   }
 
   private ChangeNotes insertChange() throws Exception {
@@ -825,10 +825,14 @@
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+        noteUtil.newAccountIdIdent(
+            getAccount(admin.id()).id(), committer.getWhen().toInstant(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index e778a5c..8bf7443 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -82,6 +83,7 @@
   @Inject private GroupOperations groupOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Inject private IndexConfig indexConfig;
   @Inject private ChangesCollection changes;
@@ -545,11 +547,8 @@
     RevCommit c2_2 = testRepo.amend(c2_1).add("b.txt", "2").create();
     testRepo.reset(c2_2);
 
-    disableChangeIndexWrites();
-    try {
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
       pushHead(testRepo, "refs/for/master", false);
-    } finally {
-      enableChangeIndexWrites();
     }
 
     PatchSet.Id psId1_1 = getPatchSetId(c1_1);
@@ -666,7 +665,7 @@
   }
 
   private void clearGroups(PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
       bu.addOp(
           psId.changeId(),
           new BatchUpdateOp() {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index a2a6caa..5a4f073 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -132,6 +132,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -154,6 +156,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -182,6 +186,9 @@
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
       assertSubmittedTogether(id3, id3, id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id3, id3, id2, id1);
     }
   }
 
@@ -249,6 +256,13 @@
       assertSubmittedTogether(id4, id4, id3, id2);
       assertSubmittedTogether(id5);
       assertSubmittedTogether(id6, id6, id5);
+
+      assertSubmittedTogetherWithTopicClosure(id1, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id6, id5, id2);
+      assertSubmittedTogetherWithTopicClosure(id3, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id4, id6, id5, id4, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id5);
+      assertSubmittedTogetherWithTopicClosure(id6, id6, id5, id2);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index 09e6dfe..b2a0ded 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -65,7 +65,7 @@
       values = {"enabledFeature"})
   @GerritConfig(
       name = "experiments.disabled",
-      values = {"UiFeature__patchset_comments"})
+      values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
   public void configOverride_defaultFeatureDisabled() {
     assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
     assertThat(
diff --git a/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java b/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java
new file mode 100644
index 0000000..e073f6f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java
@@ -0,0 +1,75 @@
+// 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.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Tests for {@link CodeReviewCommit}. */
+public class CodeReviewCommitTest {
+
+  @Test
+  public void checkSerializable_withStatusMessage() throws Exception {
+    CodeReviewCommit commit = new CodeReviewCommit(createCommit());
+    commit.setStatusMessage("Status");
+    CodeReviewCommit deserializedCommit = serializeAndReadBack(commit);
+    assertThat(deserializedCommit).isEqualTo(commit);
+    assertThat(deserializedCommit.getStatusMessage().get()).isEqualTo("Status");
+  }
+
+  @Test
+  public void checkSerializable_emptyStatusMessage() throws Exception {
+    CodeReviewCommit commit = new CodeReviewCommit(createCommit());
+    CodeReviewCommit deserializedCommit = serializeAndReadBack(commit);
+    assertThat(deserializedCommit).isEqualTo(commit);
+    assertThat(deserializedCommit.getStatusMessage().isPresent()).isFalse();
+  }
+
+  @SuppressWarnings("BanSerializableRead")
+  private CodeReviewCommit serializeAndReadBack(CodeReviewCommit codeReviewCommit)
+      throws Exception {
+    try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        ObjectOutputStream out = new ObjectOutputStream(bos)) {
+      out.writeObject(codeReviewCommit);
+      out.flush();
+      try (ByteArrayInputStream fileIn = new ByteArrayInputStream(bos.toByteArray());
+          ObjectInputStream in = new ObjectInputStream(fileIn); ) {
+        return (CodeReviewCommit) in.readObject();
+      }
+    }
+  }
+
+  private ObjectId createCommit() throws Exception {
+    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+    Project.NameKey project = Project.nameKey("test");
+    try (Repository repo = repoManager.createRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      PersonIdent ident = new PersonIdent(new PersonIdent("Test Ident", "test@test.com"));
+      return tr.commit().author(ident).committer(ident).message("Test commit").create();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 1a01184..13e2f24 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -95,10 +95,7 @@
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     String revId = result.getCommit().getName();
-    when(mockCommentValidator.validateComments(
-            CommentValidationContext.create(
-                result.getChange().getId().get(), result.getChange().project().get()),
-            ImmutableList.of(COMMENT_FOR_VALIDATION)))
+    when(mockCommentValidator.validateComments(captureCtx.capture(), capture.capture()))
         .thenReturn(ImmutableList.of());
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
     testCommentHelper.addDraft(changeId, revId, comment);
@@ -107,6 +104,12 @@
     amendResult.assertOkStatus();
     amendResult.assertNotMessage("Comment validation failure:");
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+
+    assertThat(captureCtx.getAllValues()).hasSize(1);
+    assertThat(captureCtx.getValue().getProject()).isEqualTo(result.getChange().project().get());
+    assertThat(captureCtx.getValue().getChangeId()).isEqualTo(result.getChange().getId().get());
+    assertThat(captureCtx.getValue().getRefName()).isEqualTo("refs/heads/master");
+    assertThat(capture.getValue()).containsExactly(COMMENT_FOR_VALIDATION);
   }
 
   @Test
@@ -182,7 +185,9 @@
     String revId = result.getCommit().getName();
     when(mockCommentValidator.validateComments(
             CommentValidationContext.create(
-                result.getChange().getId().get(), result.getChange().project().get()),
+                result.getChange().getId().get(),
+                result.getChange().project().get(),
+                result.getChange().change().getDest().branch()),
             ImmutableList.of(COMMENT_FOR_VALIDATION)))
         .thenReturn(ImmutableList.of(COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
@@ -215,6 +220,7 @@
 
     assertThat(captureCtx.getValue().getProject()).isEqualTo(result.getChange().project().get());
     assertThat(captureCtx.getValue().getChangeId()).isEqualTo(result.getChange().getId().get());
+    assertThat(captureCtx.getValue().getRefName()).isEqualTo("refs/heads/master");
 
     assertThat(capture.getAllValues().get(0))
         .containsExactly(
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 85238f8..6013862 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -64,6 +64,7 @@
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -599,6 +600,7 @@
   }
 
   private interface Adder {
+
     void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
         throws Exception;
   }
@@ -993,13 +995,22 @@
     StagedPreChange spc = stagePreChange("refs/for/master");
     assertThat(sender)
         .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
   }
 
   @Test
+  public void verifyTitle() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .title(String.format("[S] Change in %s[master]: test commit", project));
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
   public void createWipChange() throws Exception {
     stagePreChange("refs/for/master%wip");
     assertThat(sender).didNotSend();
@@ -1060,7 +1071,7 @@
     StagedPreChange spc = stagePreChange("refs/for/master%wip,notify=ALL");
     assertThat(sender)
         .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -1073,10 +1084,13 @@
             "refs/for/master",
             users ->
                 ImmutableList.of("r=" + users.reviewer.username(), "cc=" + users.ccer.username()));
-    FakeEmailSenderSubject subject =
-        assertThat(sender).sent("newchange", spc).to(spc.reviewer, spc.watchingProjectOwner);
-    subject.cc(spc.ccer);
-    subject.bcc(NEW_CHANGES, NEW_PATCHSETS).noOneElse();
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.reviewer)
+        .cc(spc.ccer)
+        .bcc(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -1090,8 +1104,8 @@
     assertThat(sender)
         .sent("newchange", spc)
         .to("nobody1@example.com")
-        .to(spc.watchingProjectOwner)
         .cc("nobody2@example.com")
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -1288,6 +1302,7 @@
   }
 
   private interface Stager {
+
     StagedChange stage() throws Exception;
   }
 
@@ -2271,8 +2286,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.reviewer, admin)
         .cc(sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2295,8 +2311,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.reviewer, admin)
         .cc(sc.owner, sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2320,8 +2337,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.owner, sc.reviewer, admin)
         .cc(sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2345,8 +2363,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.owner, sc.reviewer, admin)
         .cc(sc.ccer, other)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index d6fcccc..4e490a7 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -30,11 +30,10 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -100,7 +99,7 @@
     expectedHeaders.put("Gerrit-MessageType", "comment");
     expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
     expectedHeaders.put("Gerrit-ChangeURL", changeURL);
-    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date);
+    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date.toInstant());
 
     assertHeaders(message.headers(), expectedHeaders);
 
@@ -116,14 +115,14 @@
       if (entry.getValue() instanceof String) {
         assertThat(have)
             .containsEntry("X-" + entry.getKey(), new StringEmailHeader((String) entry.getValue()));
-      } else if (entry.getValue() instanceof Date) {
+      } else if (entry.getValue() instanceof Instant) {
         assertThat(have)
-            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Date) entry.getValue()));
+            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Instant) entry.getValue()));
       } else {
         throw new Exception(
             "Object has unsupported type: "
                 + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
+                + " must be java.time.Instant or java.lang.String for key "
                 + entry.getKey());
       }
     }
@@ -133,19 +132,18 @@
     for (Map.Entry<String, Object> entry : want.entrySet()) {
       if (entry.getValue() instanceof String) {
         assertThat(body).contains(entry.getKey() + ": " + entry.getValue());
-      } else if (entry.getValue() instanceof Timestamp) {
+      } else if (entry.getValue() instanceof Instant) {
         assertThat(body)
             .contains(
                 entry.getKey()
                     + ": "
                     + MailProcessingUtil.rfcDateformatter.format(
-                        ZonedDateTime.ofInstant(
-                            ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
+                        ZonedDateTime.ofInstant((Instant) entry.getValue(), ZoneId.of("UTC"))));
       } else {
         throw new Exception(
             "Object has unsupported type: "
                 + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
+                + " must be java.time.Instant or java.lang.String for key "
                 + entry.getKey());
       }
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index d4000b3..2bccc87 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -436,7 +436,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -445,7 +445,7 @@
     assertThat(message.body()).contains("rejected one or more comments");
 
     // ensure the message header contains a valid message id.
-    assertThat(((StringEmailHeader) (message.headers().get("Message-ID"))).getString())
+    assertThat(((StringEmailHeader) message.headers().get("Message-ID")).getString())
         .containsMatch("<someid-REJECTION-HTML@" + new URL(canonicalWebUrl.get()).getHost() + ">");
   }
 
@@ -465,7 +465,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -490,7 +490,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -576,7 +576,7 @@
             null);
     mailMessage.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(mailMessage.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -596,7 +596,7 @@
             CommentForValidation.CommentSource.HUMAN, type, COMMENT_TEXT, COMMENT_TEXT.length());
 
     when(mockCommentValidator.validateComments(
-            CommentValidationContext.create(failChange, failProject),
+            CommentValidationContext.create(failChange, failProject, "refs/heads/master"),
             ImmutableList.of(commentForValidation)))
         .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
index 628b90c..65b1d4f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -18,12 +18,17 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.Test;
 
 public class NotificationMailFormatIT extends AbstractDaemonTest {
@@ -56,6 +61,33 @@
   }
 
   @Test
+  public void bccUserIsNotAddedToReplyTo() throws Exception {
+    TestAccount bccUser = accountCreator.user2();
+
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    gApi.accounts().id(bccUser.id().get()).setWatchedProjects(projectsToWatch);
+
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertMailReplyTo(m, admin.email());
+    assertMailReplyTo(m, user.email());
+    assertMailNotReplyTo(m, bccUser.email());
+
+    assertThat(m.rcpt().stream().map(a -> a.email()).collect(Collectors.toSet()))
+        .contains(bccUser.email());
+  }
+
+  @Test
   public void userReceivesHtmlAndPlaintextEmail() throws Exception {
     // Create change as admin and review as user
     PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index 3c066a3..ab5e1d8 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -274,7 +274,7 @@
   }
 
   private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
-    return buf.create(project, identifiedUserFactory.create(user.id()), TimeUtil.nowTs());
+    return buf.create(project, identifiedUserFactory.create(user.id()), TimeUtil.now());
   }
 
   private Optional<ObjectId> getRef(String name) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 46687e3..3508112 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -53,6 +53,7 @@
 import com.google.inject.Module;
 import java.util.Collection;
 import java.util.Set;
+import java.util.stream.StreamSupport;
 import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -144,13 +145,14 @@
 
                       @Override
                       public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds) {
-                        return ImmutableList.copyOf(groupIds).stream().anyMatch(g -> contains(g));
+                        return StreamSupport.stream(groupIds.spliterator(), /* parallel= */ false)
+                            .anyMatch(g -> contains(g));
                       }
 
                       @Override
                       public Set<AccountGroup.UUID> intersection(
                           Iterable<AccountGroup.UUID> groupIds) {
-                        return ImmutableList.copyOf(groupIds).stream()
+                        return StreamSupport.stream(groupIds.spliterator(), /* parallel= */ false)
                             .filter(g -> contains(g))
                             .collect(toImmutableSet());
                       }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6b34cca..576c7d0 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -300,8 +300,8 @@
         value(2, "Looks good to me, approved"),
         value(1, "Looks good to me, but someone else must approve"),
         value(0, "No score"),
-        value(-1, "I would prefer this is not merged as is"),
-        value(-2, "This shall not be merged"));
+        value(-1, "I would prefer this is not submitted as is"),
+        value(-2, "This shall not be submitted"));
 
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
new file mode 100644
index 0000000..50fa3b2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -0,0 +1,245 @@
+// 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.server.project;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestOnStoreSubmitRequirementResultModifier;
+import com.google.gerrit.acceptance.TestOnStoreSubmitRequirementResultModifier.ModificationStrategy;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link OnStoreSubmitRequirementResultModifier} on the closed changes. */
+@NoHttpd
+public class OnStoreSubmitRequirementResultModifierIT extends AbstractDaemonTest {
+
+  private static final TestOnStoreSubmitRequirementResultModifier
+      TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER =
+          new TestOnStoreSubmitRequirementResultModifier();
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(OnStoreSubmitRequirementResultModifier.class)
+            .toInstance(TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER);
+      }
+    };
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  @Test
+  public void submitRequirementStored_canBeOverriddenForMergedChanges() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementStored_canBeOverriddenForAbandonedChanges() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).abandon();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementStored_notReturnedWhenHidden() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(true);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(0);
+
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+    SubmitRequirementResult result =
+        notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+    assertThat(result.submitRequirement().name()).isEqualTo("Code-Review");
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("label:Code-Review=MAX");
+  }
+
+  @Test
+  public void overrideToUnsatisfied_unsatisfied_doesNotBlockSubmission() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.FAIL);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void overrideToUnsatisfied_doesNotBlockSubmissionWithRetries() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.FAIL);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures = new ArrayDeque<>(ImmutableList.of(true));
+    gApi.changes().id(changeId).current().submit(input);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void overrideToSatisfied_doesNotBypassSubmitRequirement() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.PASS);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    assertThrows(
+        ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy) {
+    assertWithMessage(
+            "Could not find submit requirement %s with status %s, legacy=%s, (results = %s)",
+            requirementName,
+            status,
+            isLegacy,
+            results.stream()
+                .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
+                .collect(toImmutableList()))
+        .that(
+            results.stream()
+                .filter(
+                    result ->
+                        result.name.equals(requirementName)
+                            && result.status == status
+                            && result.isLegacy == isLegacy)
+                .count())
+        .isEqualTo(1);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index ba86976..d911512 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -46,16 +47,21 @@
 
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
-    Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig.Builder nc = NotifyConfig.builder();
-    nc.addAddress(addr);
-    nc.setName("new-patch-set");
-    nc.setHeader(NotifyConfig.Header.CC);
-    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
-    nc.setFilter("message:sekret");
-
+    ImmutableList<String> messageFilters =
+        ImmutableList.of("message:subject-with-tokens", "message:subject-with-tokens=secret");
+    ImmutableList.Builder<Address> watchers = ImmutableList.builder();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("watch", nc.build());
+      for (int i = 0; i < messageFilters.size(); i++) {
+        Address addr = Address.create("Watcher#" + i, String.format("watcher-%s@example.com", i));
+        watchers.add(addr);
+        NotifyConfig.Builder nc = NotifyConfig.builder();
+        nc.addAddress(addr);
+        nc.setName("new-patch-set" + i);
+        nc.setHeader(NotifyConfig.Header.CC);
+        nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
+        nc.setFilter(messageFilters.get(i));
+        u.getConfig().putNotifyConfig("watch" + i, nc.build());
+      }
       u.save();
     }
 
@@ -67,7 +73,13 @@
 
     r =
         pushFactory
-            .create(admin.newIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "super sekret subject\n\nsubject-with-tokens=secret subject",
+                "a",
+                "a2",
+                r.getChangeId())
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -80,7 +92,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(addr);
+    assertThat(m.rcpt()).containsExactlyElementsIn(watchers.build());
     assertThat(m.body()).contains("Change subject: super sekret subject\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 2\n");
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 6e19c39..731e0df 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -15,16 +15,23 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.ExtensionRegistry.PLUGIN_NAME;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
@@ -32,12 +39,21 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.project.SubmitRequirementEvaluationException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Map;
 import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
@@ -47,10 +63,16 @@
   @Inject SubmitRequirementsEvaluator evaluator;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
 
   private ChangeData changeData;
   private String changeId;
 
+  private static final String FILE_NAME = "file,txt";
+  private static final String CONTENT = "line 1\nline 2\n line 3\n";
+
   @Before
   public void setUp() throws Exception {
     PushOneCommit.Result pushResult =
@@ -91,6 +113,25 @@
   }
 
   @Test
+  public void throwingSubmitRequirementPredicate() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new ThrowingSubmitRequirementPredicate(),
+                ThrowingSubmitRequirementPredicate.OPERAND)) {
+      SubmitRequirementExpression expression =
+          SubmitRequirementExpression.create(
+              String.format("is:%s_%s", ThrowingSubmitRequirementPredicate.OPERAND, PLUGIN_NAME));
+      SubmitRequirementExpressionResult result =
+          evaluator.evaluateExpression(expression, changeData);
+      assertThat(result.status()).isEqualTo(Status.ERROR);
+      assertThat(result.errorMessage().get())
+          .isEqualTo(ThrowingSubmitRequirementPredicate.ERROR_MESSAGE);
+    }
+  }
+
+  @Test
   public void compositeExpression() throws Exception {
     SubmitRequirementExpression expression =
         SubmitRequirementExpression.create(
@@ -108,6 +149,93 @@
   }
 
   @Test
+  public void globalSubmitRequirementEvaluated() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "global-config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "project-config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(2);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideAllowed_projectResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            true);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideNotAllowedAllowed_globalResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
   public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
       throws Exception {
     SubmitRequirement sr =
@@ -121,6 +249,92 @@
   }
 
   @Test
+  public void submitRequirement_alwaysNotApplicable() {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "is:false",
+            /* submittabilityExpr= */ "is:false", // redundant
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirement_alwaysApplicable() {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "is:true",
+            /* submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void submittabilityAndOverrideNotEvaluated_whenApplicabilityIsFalse() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+    assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.FAIL);
+    assertThat(result.submittabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.overrideExpressionResult().isPresent()).isFalse();
+  }
+
+  @Test
+  public void submittabilityAndOverrideEvaluated_whenApplicabilityIsEmpty() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "label:\"build-cop-override=-1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.applicabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.FAIL);
+  }
+
+  @Test
+  public void submittabilityAndOverrideEvaluated_whenApplicabilityIsTrue() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "label:\"build-cop-override=-1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.FAIL);
+  }
+
+  @Test
+  public void submittabilityIsEvaluated_whenOverrideApplies() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "project:" + project.get());
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+
+    assertThat(result.applicabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.PASS);
+  }
+
+  @Test
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsTrue() throws Exception {
     SubmitRequirement sr =
         createSubmitRequirement(
@@ -138,13 +352,13 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
-    assertThat(result.submittabilityExpressionResult().failingAtoms())
-        .containsExactly("label:\"code-review=+2\"");
+    assertThat(result.submittabilityExpressionResult().get().failingAtoms())
+        .containsExactly("label:\"Code-Review=+2\"");
   }
 
   @Test
@@ -160,7 +374,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "label:\"build-cop-override=+1\"");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -175,7 +389,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "invalid_field:invalid_value",
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "label:\"build-cop-override=+1\"");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -197,7 +411,7 @@
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
-    assertThat(result.submittabilityExpressionResult().errorMessage().get())
+    assertThat(result.submittabilityExpressionResult().get().errorMessage().get())
         .isEqualTo("Unsupported operator invalid_field:invalid_value");
   }
 
@@ -206,7 +420,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "invalid_field:invalid_value");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -215,6 +429,434 @@
         .isEqualTo("Unsupported operator invalid_field:invalid_value");
   }
 
+  @Test
+  public void byPureRevert() throws Exception {
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "is:pure-revert",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).revert().get();
+    String revertId = Integer.toString(changeInfo._number);
+    ChangeData revertChangeData =
+        changeQueryProvider.get().byLegacyChangeId(Change.Id.tryParse(revertId).get()).get(0);
+    result = evaluator.evaluateRequirement(sr, revertChangeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void byAuthorEmail() throws Exception {
+    TestAccount user2 =
+        accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+    requestScopeOperations.setApiUser(user2.id());
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+    ChangeData cd =
+        changeQueryProvider
+            .get()
+            .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+            .get(0);
+
+    // Match by email works
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^.*@example\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^user@.*\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+
+    // Match by name does not work
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^Foo$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^User$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  private void checkSubmitRequirementResult(
+      ChangeData cd, String submittabilityExpr, SubmitRequirementResult.Status expectedStatus) {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            submittabilityExpr,
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, cd);
+    assertThat(result.status()).isEqualTo(expectedStatus);
+  }
+
+  @Test
+  public void byFileEdits_deletedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 2\n", ""))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 2'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_deletedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 1\n", ""))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 2'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_addedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT + "line 4\n")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 4'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_addedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT + "line 4\n")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 5'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_addedFile_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file("new_file.txt")
+            .content("content of the new file")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^new.*\\\\.txt',withDiffContaining='of the new'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_addedFile_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file("new_file.txt")
+            .content("content of the new file")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^new.*\\.txt',withDiffContaining='not_exist'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='ten'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContentPattern_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='^.*th[rR]ee$'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_exactMatchingWithFilePath_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            String.format("file:\"'%s',withDiffContaining='three'\"", FILE_NAME));
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_exactMatchingWithFilePath_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'non_existent.txt',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_notMatchingWithFilePath() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    // commit edit only matches with files ending with ".java". Since our modified file name ends
+    // with ".txt", the applicability expression will not match.
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.java',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_escapeSingleQuotes() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line 'three' is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\'three\\' is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_doubleEscapeSingleQuote() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            // This will be written to the file as: line \'three\' is modified.
+            .content(CONTENT.replace("line 3\n", "line \\'three\\' is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    // Users can still provide back-slashes in regexes by escaping them.
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\\\'three\\\\' is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_escapeDoubleQuotes() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line \"three\" is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\\"three\\\" is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_invalidSyntax() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining=forgot single quotes\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    // If the format is invalid, the operator falls back to the default operator of
+    // ChangeQueryBuilder which does not match the change, i.e. returns false.
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_invalidFilePattern() throws Exception {
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^**',withDiffContaining='content'\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).isEqualTo("Invalid file pattern.");
+  }
+
+  @Test
+  public void byFileEdits_invalidContentPattern() throws Exception {
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'fileName\\.txt',withDiffContaining='^**'\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).isEqualTo("Invalid content pattern.");
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
@@ -239,13 +881,55 @@
       @Nullable String applicabilityExpr,
       String submittabilityExpr,
       @Nullable String overrideExpr) {
+    return createSubmitRequirement(
+        /*name= */ "sr-name",
+        applicabilityExpr,
+        submittabilityExpr,
+        overrideExpr,
+        /*allowOverrideInChildProjects=*/ false);
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      String name,
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr,
+      boolean allowOverrideInChildProjects) {
     return SubmitRequirement.builder()
-        .setName("sr-name")
+        .setName(name)
         .setDescription(Optional.of("sr-description"))
         .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
         .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
         .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
-        .setAllowOverrideInChildProjects(false)
+        .setAllowOverrideInChildProjects(allowOverrideInChildProjects)
         .build();
   }
+
+  /** Submit requirement predicate that always throws an error on match. */
+  static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
+      implements ChangeIsOperandFactory {
+
+    public static final String OPERAND = "throwing-predicate";
+
+    public static final String ERROR_MESSAGE = "Error is storage";
+
+    public ThrowingSubmitRequirementPredicate() {
+      super("is", OPERAND);
+    }
+
+    @Override
+    public boolean match(ChangeData object) {
+      throw new SubmitRequirementEvaluationException(ERROR_MESSAGE);
+    }
+
+    @Override
+    public int getCost() {
+      return 0;
+    }
+
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+      return this;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
index 4675bc0..d8aa789 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -50,7 +50,7 @@
                 ProjectConfig.SUBMIT_REQUIREMENT,
                 /* subsection= */ submitRequirementName,
                 /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-                /* value= */ "label:\"code-review=+2\""));
+                /* value= */ "label:\"Code-Review=+2\""));
 
     PushResult r = pushRefsMetaConfig();
     assertOkStatus(r);
@@ -77,7 +77,7 @@
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName,
               /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-              /* value= */ "label:\"code-review=+2\"");
+              /* value= */ "label:\"Code-Review=+2\"");
           projectConfig.setString(
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName,
@@ -95,6 +95,78 @@
   }
 
   @Test
+  public void parametersDirectlyInSubmitRequirementsSectionAreRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ null,
+              /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+              /* value= */ "foo bar description");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ null,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirements must be defined in submit-requirement.<name>"
+                + " subsections. Setting parameters directly in the submit-requirement section is"
+                + " not allowed: [%s, %s]",
+            ProjectConfig.KEY_SR_DESCRIPTION, ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+  }
+
+  @Test
+  public void unsupportedParameterDirectlyInSubmitRequirementsSectionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ null,
+                /* name= */ "unknown",
+                /* value= */ "value"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        "project.config: Submit requirements must be defined in submit-requirement.<name>"
+            + " subsections. Setting parameters directly in the submit-requirement section is"
+            + " not allowed: [unknown]");
+  }
+
+  @Test
+  public void unsupportedParameterForSubmitRequirementIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ "unknown",
+                /* value= */ "value"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Unsupported parameters for submit requirement '%s': [unknown]",
+            submitRequirementName));
+  }
+
+  @Test
   public void conflictingSubmitRequirementsAreRejected() throws Exception {
     fetchRefsMetaConfig();
 
@@ -105,12 +177,12 @@
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName,
               /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-              /* value= */ "label:\"code-review=+2\"");
+              /* value= */ "label:\"Code-Review=+2\"");
           projectConfig.setString(
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
               /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-              /* value= */ "label:\"code-review=+2\"");
+              /* value= */ "label:\"Code-Review=+2\"");
         });
 
     PushResult r = pushRefsMetaConfig();
@@ -132,7 +204,7 @@
                 ProjectConfig.SUBMIT_REQUIREMENT,
                 /* subsection= */ submitRequirementName,
                 /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-                /* value= */ "label:\"code-review=+2\""));
+                /* value= */ "label:\"Code-Review=+2\""));
     PushResult r = pushRefsMetaConfig();
     assertOkStatus(r);
 
@@ -142,7 +214,7 @@
                 ProjectConfig.SUBMIT_REQUIREMENT,
                 /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
                 /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-                /* value= */ "label:\"code-review=+2\""));
+                /* value= */ "label:\"Code-Review=+2\""));
     r = pushRefsMetaConfig();
     assertErrorStatus(
         r,
@@ -170,8 +242,139 @@
         r,
         "Invalid project configuration",
         String.format(
-            "project.config: Submit requirement '%s' does not define a submittability expression.",
-            submitRequirementName));
+            "project.config: Setting a submittability expression for submit requirement '%s' is"
+                + " required: Missing %s.%s.%s",
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidSubmittabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ invalidExpression));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidApplicabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+              /* value= */ invalidExpression);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidOverrideExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+              /* value= */ invalidExpression);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidAllowOverrideInChildProjectsIsRejected()
+      throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidValue = "invalid";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+                /* value= */ invalidValue));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Invalid value %s.%s.%s for submit requirement '%s': %s",
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+            submitRequirementName,
+            invalidValue));
   }
 
   private void fetchRefsMetaConfig() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
index 537c7d8..b019354 100644
--- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -35,7 +35,9 @@
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import java.util.Date;
+import java.time.Instant;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 public class ApprovalQueryIT extends AbstractDaemonTest {
@@ -65,6 +67,29 @@
   }
 
   @Test
+  public void exactValuePredicate() throws Exception {
+    ApprovalContext approvalContextCodeReviewPlusOne = contextForCodeReviewLabel(1);
+    assertFalse(
+        queryBuilder.parse("is:\"-2\"").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(
+        queryBuilder.parse("is:\"-1\"").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(queryBuilder.parse("is:0").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertTrue(queryBuilder.parse("is:1").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(queryBuilder.parse("is:2").asMatchable().match(approvalContextCodeReviewPlusOne));
+  }
+
+  @Test
+  public void isPredicate_invalidValue() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("is:INVALID"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "INVALID is not a valid value for operator 'is'. Valid values: ANY, MAX, MIN"
+                + " or integer");
+  }
+
+  @Test
   public void changeKindPredicate_noCodeChange() throws Exception {
     String change = changeKindCreator.createChange(ChangeKind.NO_CODE_CHANGE, testRepo, admin);
     changeKindCreator.updateChange(change, ChangeKind.NO_CODE_CHANGE, testRepo, admin, project);
@@ -125,6 +150,17 @@
   }
 
   @Test
+  public void changeKindPredicate_invalidValue() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("changekind:INVALID"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "INVALID is not a valid value for operator 'changekind'. Valid values:"
+                + " MERGE_FIRST_PARENT_UPDATE, NO_CHANGE, NO_CODE_CHANGE, REWORK, TRIVIAL_REBASE");
+  }
+
+  @Test
   public void uploaderInPredicate() throws Exception {
     String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
 
@@ -239,7 +275,15 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains(
-            "'invalid' is not a supported argument for has. only 'unchanged-files' is supported");
+            "'invalid' is not a valid value for operator 'has'."
+                + " The only valid value is 'unchanged-files'.");
+  }
+
+  @Test
+  public void invalidQuery() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("INVALID"));
+    assertThat(thrown).hasMessageThat().contains("Unsupported query: INVALID");
   }
 
   private ApprovalContext contextForCodeReviewLabel(int value) throws Exception {
@@ -250,7 +294,7 @@
   }
 
   private ApprovalContext contextForCodeReviewLabel(
-      int value, PatchSet.Id psId, Account.Id approver) {
+      int value, PatchSet.Id psId, Account.Id approver) throws Exception {
     ChangeNotes changeNotes = changeNotesFactory.create(project, psId.changeId());
     PatchSet.Id newPsId = PatchSet.id(psId.changeId(), psId.get() + 1);
     ChangeKind changeKind =
@@ -259,11 +303,20 @@
     PatchSetApproval approval =
         PatchSetApproval.builder()
             .postSubmit(false)
-            .granted(new Date())
+            .granted(Instant.now())
             .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
             .value(value)
             .build();
-    return ApprovalContext.create(
-        changeNotes, approval, changeNotes.getPatchSets().get(newPsId), changeKind);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo.newObjectReader())) {
+      return ApprovalContext.create(
+          changeNotes,
+          approval,
+          changeNotes.getPatchSets().get(newPsId),
+          changeKind,
+          /* isMerge= */ false,
+          rw,
+          repo.getConfig());
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 6cb13c5..4f93dd6 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
@@ -41,11 +42,10 @@
  */
 @NoHttpd
 public class RulesIT extends AbstractDaemonTest {
-  private static final String RULE_TEMPLATE =
-      "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).";
-
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
+  @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private IndexOperations.Account accountIndexOperations;
 
   @Test
   public void testUnresolvedCommentsCountPredicate() throws Exception {
@@ -243,8 +243,8 @@
     ChangeData cd = result.getChange();
 
     Collection<SubmitRecord> records;
-    try (AutoCloseable ignored1 = disableChangeIndex();
-        AutoCloseable ignored2 = disableAccountIndex()) {
+    try (AutoCloseable ignored1 = changeIndexOperations.disableReadsAndWrites();
+        AutoCloseable ignored2 = accountIndexOperations.disableReadsAndWrites()) {
       SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
       records = ruleEvaluator.evaluate(cd);
     }
@@ -255,7 +255,9 @@
   }
 
   private void modifySubmitRules(String ruleTested) throws Exception {
-    String newContent = String.format(RULE_TEMPLATE, ruleTested);
+    String newContent =
+        String.format(
+            "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).", ruleTested);
 
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 3b38bad..2a06900 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -26,10 +26,10 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.Injector;
 import java.util.List;
 import org.junit.Test;
 
@@ -37,8 +37,7 @@
 @UseSsh
 public abstract class AbstractIndexTests extends AbstractDaemonTest {
   @Inject private ExtensionRegistry extensionRegistry;
-
-  public void configureIndex(Injector injector) {}
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
@@ -46,18 +45,16 @@
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      configureIndex(server.getTestInjector());
 
       PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
       String changeId = change.getChangeId();
       String changeLegacyId = change.getChange().getId().toString();
       ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-      disableChangeIndexWrites();
-      amendChange(changeId, "second test", "test2.txt", "test2");
-
-      assertChangeQuery(change.getChange(), false);
-      enableChangeIndexWrites();
+      try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+        amendChange(changeId, "second test", "test2.txt", "test2");
+        assertChangeQuery(change.getChange(), false);
+      }
 
       changeIndexedCounter.clear();
       String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
@@ -76,17 +73,15 @@
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      configureIndex(server.getTestInjector());
 
       PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
       String changeId = change.getChangeId();
       ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-      disableChangeIndexWrites();
-      amendChange(changeId, "second test", "test2.txt", "test2");
-
-      assertChangeQuery(change.getChange(), false);
-      enableChangeIndexWrites();
+      try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+        amendChange(changeId, "second test", "test2.txt", "test2");
+        assertChangeQuery(change.getChange(), false);
+      }
 
       changeIndexedCounter.clear();
       String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index cd4f571..912c464 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -49,7 +50,6 @@
 @NoHttpd
 @UseSsh
 public class QueryIT extends AbstractDaemonTest {
-
   private static Gson gson = new Gson();
 
   @Test
@@ -393,10 +393,10 @@
   }
 
   private static List<ChangeAttribute> getChanges(String rawResponse) {
-    String[] lines = rawResponse.split("\\n");
-    List<ChangeAttribute> changes = new ArrayList<>(lines.length - 1);
-    for (int i = 0; i < lines.length - 1; i++) {
-      changes.add(gson.fromJson(lines[i], ChangeAttribute.class));
+    List<String> lines = Splitter.on("\n").omitEmptyStrings().splitToList(rawResponse);
+    List<ChangeAttribute> changes = new ArrayList<>(lines.size() - 1);
+    for (int i = 0; i < lines.size() - 1; i++) {
+      changes.add(gson.fromJson(lines.get(i), ChangeAttribute.class));
     }
     return changes;
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 58c2517..2de52b2 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -50,7 +51,8 @@
 
   @Test
   public void byCommitHash() throws Exception {
-    String id = change.getCommit().getId().toString().split("\\s+")[1];
+    String id =
+        Splitter.onPattern("\\s+").splitToList(change.getCommit().getId().toString()).get(1);
     addReviewer(id);
     removeReviewer(id);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 469630f..ab84e70 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -63,7 +63,6 @@
   private static final ImmutableList<String> MASTER_ONLY_ROOT_COMMANDS =
       ImmutableList.of(
           "ban-commit",
-          "copy-approvals",
           "create-account",
           "create-branch",
           "create-group",
@@ -144,7 +143,7 @@
     // option causes the usage info to be written to stderr. Instead, we assert on the
     // content of the stderr, which will always start with "gerrit command" when the --help
     // option is used.
-    logger.atFine().log(cmd);
+    logger.atFine().log("%s", cmd);
     adminSshSession.exec(String.format("%s --help", cmd));
     String response = adminSshSession.getError();
     assertWithMessage(String.format("command %s failed: %s", cmd, response))
@@ -207,22 +206,22 @@
   private List<String> parseCommandsFromGerritHelpText(String helpText) {
     List<String> commands = new ArrayList<>();
 
-    String[] lines = helpText.split("\\n");
+    List<String> lines = Splitter.on("\n").splitToList(helpText);
 
     // Skip all lines including the line starting with "Available commands"
     int row = 0;
     do {
       row++;
-    } while (row < lines.length && !lines[row - 1].startsWith("Available commands"));
+    } while (row < lines.size() && !lines.get(row - 1).startsWith("Available commands"));
 
     // Skip all empty lines
-    while (lines[row].trim().isEmpty()) {
+    while (lines.get(row).trim().isEmpty()) {
       row++;
     }
 
     // Parse commands from all lines that are indented (start with a space)
-    while (row < lines.length && lines[row].startsWith(" ")) {
-      String line = lines[row].trim();
+    while (row < lines.size() && lines.get(row).startsWith(" ")) {
+      String line = lines.get(row).trim();
       // Abort on empty line
       if (line.isEmpty()) {
         break;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index a2d4a06..84c3936 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -32,8 +32,6 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
 import org.junit.Test;
 
 @UseSsh
@@ -122,7 +120,7 @@
   }
 
   private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+    private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
     public void log(String operation, long durationMs, Metadata metadata) {
@@ -130,7 +128,7 @@
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
+      return logEntries.build();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index e0e1880..13a9e0c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -40,7 +40,6 @@
 public class StreamEventsIT extends AbstractDaemonTest {
   private static final Duration MAX_DURATION_FOR_RECEIVING_EVENTS = Duration.ofSeconds(2);
   private static final String TEST_REVIEW_COMMENT = "any comment";
-  private StringBuilder eventsOutput = new StringBuilder();
   private Reader streamEventsReader;
 
   @Before
@@ -56,7 +55,17 @@
   @Test
   public void commentOnChangeShowsUpInStreamEvents() throws Exception {
     reviewChange(new ReviewInput().message(TEST_REVIEW_COMMENT));
-    waitForEvent(() -> pollEventsContaining(TEST_REVIEW_COMMENT).size() == 1);
+    waitForEvent(() -> pollEventsContaining("comment-added", TEST_REVIEW_COMMENT).size() == 1);
+  }
+
+  @Test
+  public void batchRefsUpdatedShowSeparatelyInStreamEvents() throws Exception {
+    String refName = createChange().getChange().currentPatchSet().refName();
+    waitForEvent(
+        () ->
+            pollEventsContaining("ref-updated", refName.substring(0, refName.lastIndexOf('/')))
+                    .size()
+                == 2);
   }
 
   private void waitForEvent(Supplier<Boolean> waitCondition) throws InterruptedException {
@@ -68,16 +77,20 @@
     changeApi.current().review(reviewInput);
   }
 
-  private List<String> pollEventsContaining(String reviewComment) {
+  private List<String> pollEventsContaining(String eventType, String expectedContent) {
     try {
       char[] cbuf = new char[2048];
+      StringBuilder eventsOutput = new StringBuilder();
       while (streamEventsReader.ready()) {
         streamEventsReader.read(cbuf);
         eventsOutput.append(cbuf);
       }
       return StreamSupport.stream(
               Splitter.on('\n').trimResults().split(eventsOutput.toString()).spliterator(), false)
-          .filter(event -> event.contains(reviewComment))
+          .filter(
+              event ->
+                  event.contains(String.format("\"type\":\"%s\"", eventType))
+                      && event.contains(expectedContent))
           .collect(Collectors.toList());
     } catch (IOException e) {
       throw new IllegalStateException(e);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index a003f9d..473b128 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -325,9 +325,9 @@
     GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
     AccountGroup.UUID groupUuid = AccountGroup.uuid(group.id);
 
-    Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
+    Instant createdOn = groupOperations.group(groupUuid).get().createdOn();
 
-    assertThat(createdOn).isEqualTo(group.createdOn);
+    assertThat(createdOn).isEqualTo(group.createdOn.toInstant());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
index 0883033..ec70aef 100644
--- a/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
+++ b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
@@ -44,7 +44,7 @@
 import org.junit.Test;
 
 public final class LdapRealmTest {
-  @Inject private LdapRealm ldapRealm = null;
+  @Inject private LdapRealm ldapRealm;
   @Inject private ExternalIdFactory externalIdFactory;
 
   @Before
diff --git a/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
index 3ec6f28..6702c7e 100644
--- a/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
@@ -34,7 +34,7 @@
 import org.junit.Test;
 
 public final class OAuthRealmTest {
-  @Inject private OAuthRealm oauthRealm = null;
+  @Inject private OAuthRealm oauthRealm;
   @Inject private ExternalIdFactory externalIdFactory;
 
   @Before
diff --git a/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
index f83409b..563f05e 100644
--- a/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
+++ b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
@@ -35,7 +35,7 @@
 import org.junit.Test;
 
 public final class OpenIdRealmTest {
-  @Inject private OpenIdRealm openidRealm = null;
+  @Inject private OpenIdRealm openidRealm;
   @Inject private ExternalIdFactory extIdFactory;
 
   @Before
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index c7b21a3..492d007 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -11,6 +11,7 @@
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/log:log4j",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
index ec71e05..d959c2a 100644
--- a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
@@ -45,6 +45,7 @@
 
   @Test
   public void cppExtensions() {
+    assertThat(comparator.compare("abc/file.hh", "abc/file.cc")).isLessThan(0);
     assertThat(comparator.compare("abc/file.h", "abc/file.cc")).isLessThan(0);
     assertThat(comparator.compare("abc/file.c", "abc/file.hpp")).isGreaterThan(0);
     assertThat(comparator.compare("abc..xyz.file.h", "abc.xyz.file.cc")).isLessThan(0);
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 593b635..477f9d2 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -127,8 +127,16 @@
     AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
     assertThat(GroupReference.create(uuid1, "foo").hashCode())
         .isEqualTo(GroupReference.create(uuid1, "bar").hashCode());
+  }
 
-    // Check that the following calls don't fail with an exception.
-    GroupReference.create("bar").hashCode();
+  @Test
+  public void testEqualsWithoutUuid() {
+    assertThat(GroupReference.create("foo").equals(GroupReference.create("bar"))).isTrue();
+  }
+
+  @Test
+  public void testHashCodeWithoutUuid() {
+    assertThat(GroupReference.create("foo").hashCode())
+        .isEqualTo(GroupReference.create("bar").hashCode());
   }
 }
diff --git a/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
new file mode 100644
index 0000000..a0fff22
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
@@ -0,0 +1,32 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ChangeSizeBucketTest {
+
+  @Test
+  public void getChangeSizeBucket() {
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(0)).isEqualTo("NoOp");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1)).isEqualTo("XS");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(10)).isEqualTo("S");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(50)).isEqualTo("M");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(250)).isEqualTo("L");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1000)).isEqualTo("XL");
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
index 0132697..80d97db 100644
--- a/javatests/com/google/gerrit/entities/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
@@ -94,7 +93,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
         .value(value)
-        .granted(Date.from(Instant.now()))
+        .granted(Instant.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index ec6c372..22daf5b 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class ChangeMessageProtoConverterTest {
@@ -40,7 +40,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             "This is a change message.",
             Account.id(10003),
@@ -73,7 +73,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13));
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
@@ -140,7 +140,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             "This is a change message.",
             Account.id(10003),
@@ -157,7 +157,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             String.format(
                 "This is a change message by %s and includes %s ",
@@ -178,7 +178,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13));
 
     ChangeMessage convertedChangeMessage =
@@ -205,7 +205,7 @@
             ImmutableMap.<String, Type>builder()
                 .put("key", ChangeMessage.Key.class)
                 .put("author", Account.Id.class)
-                .put("writtenOn", Timestamp.class)
+                .put("writtenOn", Instant.class)
                 .put("message", String.class)
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index 8c5e449..bd4b2b1 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class ChangeProtoConverterTest {
@@ -41,8 +41,8 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
-            new Timestamp(987654L));
-    change.setLastUpdatedOn(new Timestamp(1234567L));
+            Instant.ofEpochMilli(987654L));
+    change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
@@ -90,7 +90,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -125,7 +125,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
     // O as ID actually means that no current patch set is present.
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
 
@@ -162,7 +162,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
@@ -198,8 +198,8 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
-    change.setLastUpdatedOn(new Timestamp(1234567L));
+            Instant.ofEpochMilli(987654L));
+    change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
@@ -223,7 +223,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
     assertEqualChange(convertedChange, change);
@@ -242,8 +242,8 @@
     assertThat(change.getKey()).isNull();
     assertThat(change.getOwner()).isNull();
     assertThat(change.getDest()).isNull();
-    assertThat(change.getCreatedOn()).isEqualTo(new Timestamp(0));
-    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(0));
+    assertThat(change.getCreatedOn()).isEqualTo(Instant.EPOCH);
+    assertThat(change.getLastUpdatedOn()).isEqualTo(Instant.EPOCH);
     assertThat(change.getSubject()).isNull();
     assertThat(change.currentPatchSetId()).isNull();
     // Default values for unset protobuf fields which can't be unset in the entity object.
@@ -268,7 +268,7 @@
             .build();
     Change change = changeProtoConverter.fromProto(proto);
 
-    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
+    assertThat(change.getLastUpdatedOn()).isEqualTo(Instant.ofEpochMilli(987654L));
   }
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
@@ -279,8 +279,8 @@
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("changeKey", Change.Key.class)
-                .put("createdOn", Timestamp.class)
-                .put("lastUpdatedOn", Timestamp.class)
+                .put("createdOn", Instant.class)
+                .put("lastUpdatedOn", Instant.class)
                 .put("owner", Account.Id.class)
                 .put("dest", BranchNameKey.class)
                 .put("status", char.class)
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index d332f8a..28f9cdb 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -28,8 +28,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -44,8 +43,9 @@
             .key(
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
@@ -64,6 +64,7 @@
                             .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
                     .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
+            .setUuid("577fb248e474018276351785930358ec0450e9f7")
             .setValue(456)
             .setGranted(987654L)
             .setTag("tag-21")
@@ -82,7 +83,7 @@
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
@@ -113,8 +114,9 @@
             .key(
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
@@ -134,7 +136,7 @@
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .build();
 
     PatchSetApproval convertedPatchSetApproval =
@@ -164,7 +166,7 @@
     assertThat(patchSetApproval.labelId()).isEqualTo(LabelId.create("label-8"));
     // Default values for unset protobuf fields which can't be unset in the entity object.
     assertThat(patchSetApproval.value()).isEqualTo(0);
-    assertThat(patchSetApproval.granted()).isEqualTo(new Timestamp(0));
+    assertThat(patchSetApproval.granted()).isEqualTo(Instant.EPOCH);
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
     assertThat(patchSetApproval.copied()).isEqualTo(false);
   }
@@ -176,8 +178,9 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
+                .put("uuid", new TypeLiteral<Optional<PatchSetApproval.UUID>>() {}.getType())
                 .put("value", short.class)
-                .put("granted", Timestamp.class)
+                .put("granted", Instant.class)
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index efeb24f..3a534e9 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -42,7 +42,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
@@ -74,7 +74,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
     Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
@@ -100,7 +100,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
@@ -118,7 +118,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
     PatchSet convertedPatchSet =
@@ -143,7 +143,7 @@
                 .id(PatchSet.id(Change.id(103), 73))
                 .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
                 .uploader(Account.id(0))
-                .createdOn(new Timestamp(0))
+                .createdOn(Instant.EPOCH)
                 .build());
   }
 
@@ -156,7 +156,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
-                .put("createdOn", Timestamp.class)
+                .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
index 1d021f7..a1f6cef 100644
--- a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
@@ -33,44 +33,38 @@
 
 @RunWith(JUnit4.class)
 public class RefUpdateUtilTest {
-  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
-  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
-      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
-  private static final Consumer<ReceiveCommand> REJECTED =
-      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
-  private static final Consumer<ReceiveCommand> ABORTED =
-      c -> {
-        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
-        ReceiveCommand.abort(ImmutableList.of(c));
-        checkState(
-            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
-                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
-                && c.getResult() != ReceiveCommand.Result.OK,
-            "unexpected state after abort: %s",
-            c);
-      };
-
   @Test
   public void checkBatchRefUpdateResults() throws Exception {
     checkResults();
-    checkResults(OK);
-    checkResults(OK, OK);
+    checkResults(RefUpdateUtilTest::ok);
+    checkResults(RefUpdateUtilTest::ok, RefUpdateUtilTest::ok);
 
-    assertIoException(REJECTED);
-    assertIoException(OK, REJECTED);
-    assertIoException(LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, OK);
-    assertIoException(LOCK_FAILURE, REJECTED, OK);
-    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, OK);
+    assertIoException(RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::ok, RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::ok);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::rejected, RefUpdateUtilTest::ok);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::rejected);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted, RefUpdateUtilTest::rejected);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted, RefUpdateUtilTest::ok);
 
-    assertLockFailureException(LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
-    assertLockFailureException(ABORTED);
-    assertLockFailureException(ABORTED, ABORTED);
+    assertLockFailureException(RefUpdateUtilTest::lockFailure);
+    assertLockFailureException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::lockFailure);
+    assertLockFailureException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted);
+    assertLockFailureException(
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::aborted,
+        RefUpdateUtilTest::aborted);
+    assertLockFailureException(RefUpdateUtilTest::aborted);
+    assertLockFailureException(RefUpdateUtilTest::aborted, RefUpdateUtilTest::aborted);
   }
 
   @SafeVarargs
@@ -110,4 +104,27 @@
       return bru;
     }
   }
+
+  private static void ok(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.OK);
+  }
+
+  private static void lockFailure(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  }
+
+  private static void rejected(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  }
+
+  private static void aborted(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+    ReceiveCommand.abort(ImmutableList.of(c));
+    checkState(
+        c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+            && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+            && c.getResult() != ReceiveCommand.Result.OK,
+        "unexpected state after abort: %s",
+        c);
+  }
 }
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 7703fb0..8bafafe 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -38,11 +38,12 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testing.TestKey;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -212,9 +213,11 @@
     String problem = "Key is revoked (key material has been compromised): test6 compromised";
     assertProblems(k, problem);
 
-    SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-    PublicKeyChecker checker =
-        new PublicKeyChecker().setStore(store).setEffectiveTime(df.parse("2010-01-01 12:00:00"));
+    Instant instant =
+        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.systemDefault())
+            .parse("2010-01-01 12:00:00", Instant::from);
+    PublicKeyChecker checker = new PublicKeyChecker().setStore(store).setEffectiveTime(instant);
     assertProblems(checker, k, problem);
   }
 
@@ -360,8 +363,8 @@
         + " is valid, but key is not trusted";
   }
 
-  private static Date parseDate(String str) throws Exception {
-    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse(str);
+  private static Instant parseDate(String str) throws Exception {
+    return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z").parse(str, Instant::from);
   }
 
   private static List<String> list(String first, String[] rest) {
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 25334fd..71695f3 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.Base64;
 import java.util.Optional;
 import javax.servlet.FilterChain;
@@ -73,8 +74,6 @@
 
   @Mock private AccountState accountState;
 
-  @Mock private Account account;
-
   @Mock private AccountManager accountManager;
 
   @Mock private AuthConfig authConfig;
@@ -106,12 +105,12 @@
     authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
     pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
 
+    Account account = Account.builder(Account.id(1000000), Instant.now()).build();
     authSuccessful =
         new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
     doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
     doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
     doReturn(account).when(accountState).account();
-    doReturn(true).when(account).isActive();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
 
     doReturn(new WebSessionManager.Key(AUTH_COOKIE_VALUE)).when(webSessionManager).createKey(any());
@@ -170,7 +169,6 @@
     requestBasicAuth(req);
     res.setStatus(HttpServletResponse.SC_OK);
 
-    doReturn(true).when(account).isActive();
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
     ProjectBasicAuthFilter basicAuthFilter =
@@ -267,7 +265,6 @@
     initMockedWebSession();
     requestBasicAuth(req);
 
-    doReturn(true).when(account).isActive();
     doThrow(new AccountException("Authentication error")).when(accountManager).authenticate(any());
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
@@ -291,7 +288,6 @@
   private void doFilterForRequestWhenAlreadySignedIn()
       throws IOException, ServletException, AccountException {
     initMockedWebSession();
-    doReturn(true).when(account).isActive();
     doReturn(true).when(webSession).isSignedIn();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
     requestBasicAuth(req);
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 6691587..cc1ee00 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -116,7 +116,7 @@
 
     assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
         .containsAtLeast(
-            "defaultChangeDetailHex", "916314",
+            "defaultChangeDetailHex", "1916314",
             "changeRequestsPath", "changes/project~123");
   }
 
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index 634231f..f65e823 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -105,7 +105,7 @@
     assertThat(output)
         .contains(
             "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22"
-                + String.join("\\x22,", expectedEnabled)
+                + String.join("\\x22,\\x22", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
index 9b70159..d789201 100644
--- a/javatests/com/google/gerrit/index/query/PredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/PredicateTest.java
@@ -18,6 +18,7 @@
 
 @Ignore
 public abstract class PredicateTest {
+  @SuppressWarnings("ProtectedMembersInFinalClass")
   protected static class TestDataSourcePredicate extends TestMatchablePredicate<String>
       implements DataSource<String> {
     protected final int cardinality;
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index 776a2c4..2ff56a8 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -17,7 +17,9 @@
 import static com.google.gerrit.index.query.QueryParser.AND;
 import static com.google.gerrit.index.query.QueryParser.COLON;
 import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
+import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
 import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
+import static com.google.gerrit.index.query.QueryParser.NOT;
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
@@ -204,6 +206,156 @@
     assertThat(r).child(2).hasNoChildren();
   }
 
+  @Test
+  public void fieldNameWithEscapedDoubleQuotesInValue() throws Exception {
+    // Actual String: A \"special\" word
+    String search = "message:\"A \\\"special\\\" word\"";
+    Tree r = parse(search);
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).hasText("message");
+    assertThat(r).child(0).hasType(EXACT_PHRASE);
+    // Antlr escaped the double quotes in the phrase.
+    assertThat(r).child(0).hasText("A \"special\" word");
+  }
+
+  @Test
+  public void fieldNameWithEscapedTabCharacterIsPreserved() throws Exception {
+    String[] searches = {"message:\"A \\t word\"", "message:{A \\t word}"};
+    for (String search : searches) {
+      Tree r = parse(search);
+      assertThat(r).hasType(FIELD_NAME);
+      assertThat(r).hasChildCount(1);
+      assertThat(r).hasText("message");
+      assertThat(r).child(0).hasType(EXACT_PHRASE);
+      assertThat(r).child(0).hasText("A \t word");
+    }
+  }
+
+  @Test
+  public void fieldNameWithEscapedBackslashIsIncludedInOutput() throws Exception {
+    String search = "message:\"A backslash \\\\ in phrase\"";
+    Tree r = parse(search);
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).hasText("message");
+    assertThat(r).child(0).hasType(EXACT_PHRASE);
+    assertThat(r).child(0).hasText("A backslash \\ in phrase");
+  }
+
+  @Test
+  public void fieldNameWithNot() throws Exception {
+    Tree r = parse("-foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasText("NOT");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("bar");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithDigit() throws Exception {
+    Tree r = parse("foo9:bar");
+    assertThat(r).hasType(DEFAULT_FIELD);
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("foo9");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("bar");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithUnderscore() throws Exception {
+    Tree r = parse("foo_bar:baz");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("foo_bar");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("baz");
+    assertThat(r).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithHyphen() throws Exception {
+    Tree r = parse("foo-bar:baz");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("foo-bar");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("baz");
+    assertThat(r).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameEndingWithHyphen() throws Exception {
+    Tree r = parse("foo-:bar");
+    assertThat(r).hasType(DEFAULT_FIELD);
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("foo-");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("bar");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithNotAndHyphen() throws Exception {
+    Tree r = parse("-foo-bar:baz");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasText("NOT");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo-bar");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("baz");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithNotAndEndingWithHyphen() throws Exception {
+    Tree r = parse("-foo-bar-:baz");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(DEFAULT_FIELD);
+    assertThat(r).child(0).hasChildCount(3);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("foo-bar-");
+    assertThat(r).child(0).child(0).hasNoChildren();
+    assertThat(r).child(0).child(1).hasType(COLON);
+    assertThat(r).child(0).child(1).hasText(":");
+    assertThat(r).child(0).child(1).hasNoChildren();
+    assertThat(r).child(0).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(2).hasText("baz");
+    assertThat(r).child(0).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithMiscellaneousCharacters() throws Exception {
+    Tree r = parse("-foo-bar_-baz_:qux");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo-bar_-baz_");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("qux");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
   private static void assertParseFails(String query) {
     assertThrows(QueryParseException.class, () -> parse(query));
   }
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index acf9a50..d40f2a1 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -27,7 +29,11 @@
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -40,7 +46,10 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import java.io.File;
+import java.io.IOException;
 import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -62,9 +71,11 @@
   @Inject private GerritApi gApi;
   @Inject private AccountCreator accountCreator;
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
   @Inject private @TestSshServerAddress InetSocketAddress sshAddress;
   @Inject private @GerritServerConfig Config config;
   @Inject private AllProjectsName allProjectsName;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @BeforeClass
   public static void assertGitClientVersion() throws Exception {
@@ -86,15 +97,20 @@
       Project.NameKey project = Project.nameKey("foo");
       gApi.projects().create(project.get());
 
-      // Set up project permission
+      // Clear all permissions for anonymous users. Allow registered users to fetch/push.
+      AccountGroup.UUID admins = groupOperations.newGroup().addMember(admin.id()).create();
       projectOperations
-          .project(project)
+          .project(allProjectsName)
           .forUpdate()
-          .add(deny(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .removeAllAccessSections()
           .add(
               allow(Permission.READ)
                   .ref("refs/heads/master")
                   .group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.READ).ref("refs/*").group(admins))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(admins))
           .update();
 
       // Retrieve HTTP url
@@ -211,15 +227,17 @@
       Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
       gApi.projects().create(allRefsVisibleProject.get());
 
-      // Set up project permission to allow reading all refs
+      // Allow registered users to fetch/push. Allow anonymous users to read refs/heads/* which also
+      // allows reading changes.
       projectOperations
-          .project(allRefsVisibleProject)
+          .project(allProjectsName)
           .forUpdate()
+          .removeAllAccessSections()
           .add(allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
           .add(
-              allow(Permission.READ)
-                  .ref("refs/changes/*")
-                  .group(SystemGroupBackend.ANONYMOUS_USERS))
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
       // Create new change and retrieve refs for the created patch set
@@ -265,6 +283,140 @@
       Project.NameKey privateProject = Project.nameKey("private-project");
       gApi.projects().create(privateProject.get());
 
+      // Clear all permissions for anonymous users. Allow registered users to fetch/push.
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .removeAllAccessSections()
+          .add(
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(privateProject.get(), "master", "Test private change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput changeWeDidNotAskForIn =
+          new ChangeInput(privateProject.get(), "stable", "Test private change 2");
+      changeWeDidNotAskForIn.newBranch = true;
+      int changeWeDidNotAskForNumber = gApi.changes().create(changeWeDidNotAskForIn).info()._number;
+      Change.Id changeWeDidNotAskFor = Change.id(changeWeDidNotAskForNumber);
+      String changeWeDidNotAskForRef = RefNames.patchSetRef(PatchSet.id(changeWeDidNotAskFor, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_FETCH)
+                  .add(urlWithCredentials + "/" + privateProject.get())
+                  .add(visibleChangeNumberRef)
+                  .build(),
+              ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(visibleChangeNumberRef);
+      // refs-in-wants should not advertise changes we did not ask for
+      assertThat(outFetchRef).doesNotContain(changeWeDidNotAskForRef);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_doesNotNeedChangeIndex() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
+      // Disallow general read permissions for anonymous users
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(privateProject.get(), "master", "Test private change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput changeWeDidNotAskForIn =
+          new ChangeInput(privateProject.get(), "stable", "Test private change 2");
+      changeWeDidNotAskForIn.newBranch = true;
+      int changeWeDidNotAskForNumber = gApi.changes().create(changeWeDidNotAskForIn).info()._number;
+      Change.Id changeWeDidNotAskFor = Change.id(changeWeDidNotAskForNumber);
+      String changeWeDidNotAskForRef = RefNames.patchSetRef(PatchSet.id(changeWeDidNotAskFor, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef;
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        outFetchRef =
+            execute(
+                ImmutableList.<String>builder()
+                    .add(GIT_FETCH)
+                    .add(urlWithCredentials + "/" + privateProject.get())
+                    .add(visibleChangeNumberRef)
+                    .build(),
+                ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      }
+
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(visibleChangeNumberRef);
+      // refs-in-wants should not advertise changes we did not ask for
+      assertThat(outFetchRef).doesNotContain(changeWeDidNotAskForRef);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_doesNotNeedNoteDbWhenAskedForManyChanges()
+      throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
       // Disallow general read permissions for anonymous users
       projectOperations
           .project(allProjectsName)
@@ -286,28 +438,125 @@
                   .group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
-      // Create new change and retrieve refs for the created patch set
-      ChangeInput visibleChangeIn =
-          new ChangeInput(privateProject.get(), "master", "Test private change");
-      visibleChangeIn.newBranch = true;
-      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
-      Change.Id changeId = Change.id(visibleChangeNumber);
-      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+      List<String> changeRefs = new ArrayList<>();
+      for (int i = 0; i < 10; i++) {
+        // Create new change and retrieve refs for the created patch set
+        ChangeInput visibleChangeIn =
+            new ChangeInput(privateProject.get(), "master", "Test private change");
+        visibleChangeIn.newBranch = true;
+        int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+        Change.Id changeId = Change.id(visibleChangeNumber);
+        changeRefs.add(RefNames.patchSetRef(PatchSet.id(changeId, 1)));
+      }
 
       // Fetch a single ref using git wire protocol v2 over HTTP with authentication
       execute(GIT_INIT);
 
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        // Since we ask for many changes at once, the server will use the change index to speed up
+        // filtering. Having that disabled fails.
+        assertThrows(
+            IOException.class,
+            () ->
+                execute(
+                    ImmutableList.<String>builder()
+                        .add(GIT_FETCH)
+                        .add(urlWithCredentials + "/" + privateProject.get())
+                        .addAll(changeRefs)
+                        .build(),
+                    ImmutableMap.of("GIT_TRACE_PACKET", "1")));
+      }
+
+      // The same call succeeds if the change index is enabled.
       String outFetchRef =
           execute(
               ImmutableList.<String>builder()
                   .add(GIT_FETCH)
                   .add(urlWithCredentials + "/" + privateProject.get())
-                  .add(visibleChangeNumberRef)
+                  .addAll(changeRefs)
                   .build(),
               ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(changeRefs.get(0));
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_anonymousCantSeeInvisibleChange()
+      throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      String url = config.getString("gerrit", null, "canonicalweburl");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
+      // Disallow general read permissions for anonymous users except on master
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .removeAllAccessSections()
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn = new ChangeInput(privateProject.get(), "master", "Visible");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput invisibleChangeIn = new ChangeInput(privateProject.get(), "stable", "Invisible");
+      invisibleChangeIn.newBranch = true;
+      int invisibleChangeNumber = gApi.changes().create(invisibleChangeIn).info()._number;
+      Change.Id invisibleChange = Change.id(invisibleChangeNumber);
+      String invisibleChangeRef = RefNames.patchSetRef(PatchSet.id(invisibleChange, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef;
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        outFetchRef =
+            execute(
+                ImmutableList.<String>builder()
+                    .add(GIT_FETCH)
+                    .add(url + "/" + privateProject.get())
+                    .add(visibleChangeNumberRef)
+                    .build(),
+                ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      }
 
       assertThat(outFetchRef).contains("git< version 2");
       assertThat(outFetchRef).contains(visibleChangeNumberRef);
+
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        // Fetching invisible ref fails
+        assertThrows(
+            IOException.class,
+            () ->
+                execute(
+                    ImmutableList.<String>builder()
+                        .add(GIT_FETCH)
+                        .add(url + "/" + privateProject.get())
+                        .add(invisibleChangeRef)
+                        .build(),
+                    ImmutableMap.of("GIT_TRACE_PACKET", "1")));
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
index a219cc2..039bc8e 100644
--- a/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
+++ b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
@@ -31,6 +32,7 @@
 import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.nio.file.Files;
+import java.util.List;
 import org.junit.Test;
 
 @NoHttpd
@@ -59,17 +61,19 @@
       // Generate private/public key for user
       execute(ImmutableList.<String>builder().add(SSH_KEYGEN_CMD).build());
 
-      String[] parts =
-          new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8)
-              .split(" ");
+      List<String> parts =
+          Splitter.on(" ")
+              .splitToList(
+                  new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8));
 
       // Loose algorithm at index 0, verify the format: "key comment"
       Files.write(
-          sitePaths.peer_keys, String.format("%s %s", parts[1], parts[2]).getBytes(ISO_8859_1));
+          sitePaths.peer_keys,
+          String.format("%s %s", parts.get(1), parts.get(2)).getBytes(ISO_8859_1));
       assertContent(execGerritVersionCommand());
 
       // Only preserve the key material: no algorithm and no comment
-      Files.write(sitePaths.peer_keys, parts[1].getBytes(ISO_8859_1));
+      Files.write(sitePaths.peer_keys, parts.get(1).getBytes(ISO_8859_1));
       assertContent(execGerritVersionCommand());
 
       // Wipe out the content of the peer keys file
diff --git a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
index 2699c3b..44ef822 100644
--- a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
+++ b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.JsonPrimitive;
 import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class SqlTimestampDeserializerTest {
@@ -28,6 +28,6 @@
   @Test
   public void emptyStringIsDeserializedToMagicTimestamp() {
     Timestamp timestamp = deserializer.deserialize(new JsonPrimitive(""), Timestamp.class, null);
-    assertThat(timestamp).isEqualTo(TimeUtil.never());
+    assertThat(timestamp).isEqualTo(Timestamp.from(Instant.EPOCH));
   }
 }
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index a2432a2..f99a2af 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
@@ -58,7 +57,7 @@
         new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
-            new Timestamp(0L),
+            Instant.EPOCH,
             (short) 0,
             message,
             "",
@@ -73,7 +72,7 @@
         new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
-            new Timestamp(0L),
+            Instant.EPOCH,
             (short) 0,
             message,
             "",
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 837e69d..c694a87 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -55,6 +55,7 @@
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/account/externalids/testing",
+        "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/cancellation",
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 463af35..855a0bc 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -99,7 +99,7 @@
     injector.injectMembers(this);
 
     Account account =
-        Account.builder(Account.id(1), TimeUtil.nowTs())
+        Account.builder(Account.id(1), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     Account.Id ownerId = account.id();
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index a2aa40b..c1eff15 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.CachedPreferences;
-import java.sql.Timestamp;
 import java.time.Instant;
 import org.junit.Test;
 
@@ -32,8 +31,7 @@
  * of the {@code AccountCache}.
  */
 public class AccountCacheTest {
-  private static final Account ACCOUNT =
-      Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH)).build();
+  private static final Account ACCOUNT = Account.builder(Account.id(1), Instant.EPOCH).build();
   private static final Cache.AccountProto ACCOUNT_PROTO =
       Cache.AccountProto.newBuilder().setId(1).setRegisteredOn(0).build();
   private static final CachedAccountDetails.Serializer SERIALIZER =
@@ -42,7 +40,7 @@
   @Test
   public void account_roundTrip() throws Exception {
     Account account =
-        Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH))
+        Account.builder(Account.id(1), Instant.EPOCH)
             .setFullName("foo bar")
             .setDisplayName("foo")
             .setActive(false)
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 5e49aaf..3658834 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -96,15 +96,15 @@
             new TestSearcher("foo", false, newAccount(1)),
             new TestSearcher("bar", false, newAccount(2), newAccount(3)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("foo");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
 
-    result = search("bar", searchers, allVisible());
+    result = search("bar", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("bar");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(2, 3));
 
-    result = search("baz", searchers, allVisible());
+    result = search("baz", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("baz");
     assertThat(result.asIdSet()).isEmpty();
   }
@@ -115,11 +115,11 @@
         ImmutableList.of(
             new TestSearcher("f.*", true), new TestSearcher("foo|bar", false, newAccount(1)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("foo");
     assertThat(result.asIdSet()).isEmpty();
 
-    result = search("bar", searchers, allVisible());
+    result = search("bar", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("bar");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
   }
@@ -129,7 +129,7 @@
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, newAccount(1), newAccount(2)));
 
-    assertThat(search("foo", searchers, allVisible()).asIdSet())
+    assertThat(search("foo", searchers, AccountResolverTest::allVisiblePredicate).asIdSet())
         .containsExactlyElementsIn(ids(1, 2));
     assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(2));
   }
@@ -152,7 +152,7 @@
             new TestSearcher("foo", false, newInactiveAccount(1)),
             new TestSearcher("f.*", false, newInactiveAccount(2)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     // Searchers always short-circuit when finding a non-empty result list, and this one didn't
     // filter out inactive results, so the second searcher never ran.
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
@@ -168,7 +168,7 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible(), (a) -> true);
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate, (a) -> true);
     // Searchers always short-circuit when finding a non-empty result list,
     // and this one didn't filter out inactive results,
     // so the second searcher never ran.
@@ -185,8 +185,9 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible());
-    assertThat(search("foo", searchers, allVisible()).asIdSet()).containsExactlyElementsIn(ids(2));
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
+    assertThat(search("foo", searchers, AccountResolverTest::allVisiblePredicate).asIdSet())
+        .containsExactlyElementsIn(ids(2));
     // No info about inactive results exposed if there was at least one active result.
     assertThat(filteredInactiveIds(result)).isEmpty();
   }
@@ -199,7 +200,7 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).isEmpty();
     assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(1, 2));
   }
@@ -217,7 +218,7 @@
 
     // searcher1 matched, but filtered out all candidates because account2 is inactive. Actual
     // result came from searcher2 instead.
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1, 2));
   }
 
@@ -233,7 +234,7 @@
 
     // searcher1 matched and then filtered out all candidates because account2 is inactive, but
     // still short-circuited.
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).isEmpty();
     assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(2));
   }
@@ -242,11 +243,10 @@
   public void asUniqueWithNoResults() throws Exception {
     String input = "foo";
     ImmutableList<Searcher<?>> searchers = ImmutableList.of();
-    Supplier<Predicate<AccountState>> visibilitySupplier = allVisible();
     UnresolvableAccountException thrown =
         assertThrows(
             UnresolvableAccountException.class,
-            () -> search(input, searchers, visibilitySupplier).asUnique());
+            () -> search(input, searchers, AccountResolverTest::allVisiblePredicate).asUnique());
     assertThat(thrown).hasMessageThat().isEqualTo("Account 'foo' not found");
   }
 
@@ -255,7 +255,11 @@
     AccountState account = newAccount(1);
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, account));
-    assertThat(search("foo", searchers, allVisible()).asUnique().account().id())
+    assertThat(
+            search("foo", searchers, AccountResolverTest::allVisiblePredicate)
+                .asUnique()
+                .account()
+                .id())
         .isEqualTo(account.account().id());
   }
 
@@ -266,7 +270,7 @@
     UnresolvableAccountException thrown =
         assertThrows(
             UnresolvableAccountException.class,
-            () -> search("foo", searchers, allVisible()).asUnique());
+            () -> search("foo", searchers, AccountResolverTest::allVisiblePredicate).asUnique());
     assertThat(thrown)
         .hasMessageThat()
         .isEqualTo(
@@ -339,7 +343,7 @@
       ImmutableList<Searcher<?>> searchers,
       Supplier<Predicate<AccountState>> visibilitySupplier)
       throws Exception {
-    return search(input, searchers, visibilitySupplier, activityPrediate());
+    return search(input, searchers, visibilitySupplier, AccountResolverTest::isActive);
   }
 
   private Result search(
@@ -357,13 +361,13 @@
 
   private AccountState newAccount(int id) {
     return AccountState.forAccount(
-        Account.builder(Account.id(id), TimeUtil.nowTs())
+        Account.builder(Account.id(id), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build());
   }
 
   private AccountState newInactiveAccount(int id) {
-    Account.Builder a = Account.builder(Account.id(id), TimeUtil.nowTs());
+    Account.Builder a = Account.builder(Account.id(id), TimeUtil.now());
     a.setActive(false);
     return AccountState.forAccount(a.build());
   }
@@ -372,12 +376,17 @@
     return Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
   }
 
-  private static Supplier<Predicate<AccountState>> allVisible() {
-    return () -> a -> true;
+  private static Predicate<AccountState> allVisiblePredicate() {
+    return AccountResolverTest::allVisible;
   }
 
-  private Predicate<AccountState> activityPrediate() {
-    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();
   }
 
   private static Supplier<Predicate<AccountState>> only(int... ids) {
diff --git a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
index 1381c75..1a7d1fb 100644
--- a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.entities.Account;
 import java.util.ArrayList;
 import java.util.List;
@@ -125,18 +126,22 @@
   public void getters() throws Exception {
     AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1);
     assertThat(key.sshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
+
+    List<String> keyParts = Splitter.on(" ").splitToList(KEY1);
+    assertThat(key.algorithm()).isEqualTo(keyParts.get(0));
+    assertThat(key.encodedKey()).isEqualTo(keyParts.get(1));
+    assertThat(key.comment()).isEqualTo(keyParts.get(2));
   }
 
   @Test
   public void keyWithNewLines() throws Exception {
     AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1_WITH_NEWLINES);
     assertThat(key.sshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
+
+    List<String> keyParts = Splitter.on(" ").splitToList(KEY1);
+    assertThat(key.algorithm()).isEqualTo(keyParts.get(0));
+    assertThat(key.encodedKey()).isEqualTo(keyParts.get(1));
+    assertThat(key.comment()).isEqualTo(keyParts.get(2));
   }
 
   private static String toWindowsLineEndings(String s) {
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 1b5e951..8f2d613 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -33,7 +33,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.util.function.Consumer;
 import org.eclipse.jgit.lib.Config;
@@ -64,23 +63,14 @@
 
   @Before
   public void setUp() throws Exception {
-    externalIdFactory =
-        new ExternalIdFactory(
-            new ExternalIdKeyFactory(
-                new ExternalIdKeyFactory.Config() {
-                  @Override
-                  public boolean isUserNameCaseInsensitive() {
-                    return false;
-                  }
-                }),
-            authConfig);
+    externalIdFactory = new ExternalIdFactory(new ExternalIdKeyFactory(() -> false), authConfig);
     externalIdCache = CacheBuilder.newBuilder().build();
     repoManager.createRepository(ALL_USERS).close();
     externalIdReader =
         new ExternalIdReader(
             repoManager, ALL_USERS, new DisabledMetricMaker(), externalIdFactory, authConfig);
     externalIdReaderSpy = Mockito.spy(externalIdReader);
-    loader = createLoader(true);
+    loader = createLoader();
   }
 
   @Test
@@ -97,7 +87,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -109,7 +100,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -122,16 +114,6 @@
   }
 
   @Test
-  public void partialReloadingDisabledAlwaysTriggersFullReload() throws Exception {
-    loader = createLoader(false);
-    insertExternalId(1, 1);
-    ObjectId head = insertExternalId(2, 2);
-
-    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verify(externalIdReaderSpy, times(1)).all(head);
-  }
-
-  @Test
   public void fallsBackToFullReloadOnManyUpdatesOnBranch() throws Exception {
     insertExternalId(1, 1);
     ObjectId head = null;
@@ -159,7 +141,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -174,7 +157,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -191,7 +175,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -204,7 +189,8 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -218,19 +204,18 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
-  private ExternalIdCacheLoader createLoader(boolean allowPartial) {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", allowPartial);
+  private ExternalIdCacheLoader createLoader() {
     return new ExternalIdCacheLoader(
         repoManager,
         ALL_USERS,
         externalIdReaderSpy,
-        Providers.of(externalIdCache),
+        externalIdCache,
         new DisabledMetricMaker(),
-        cfg,
+        new Config(),
         externalIdFactory);
   }
 
@@ -281,8 +266,7 @@
   private ObjectId performExternalIdUpdate(Consumer<ExternalIdNotes> update) throws Exception {
     try (Repository repo = repoManager.openRepository(ALL_USERS)) {
       PersonIdent updater = new PersonIdent("Foo bar", "foo@bar.com");
-      ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(ALL_USERS, repo, externalIdFactory, false);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.load(ALL_USERS, repo, externalIdFactory, false);
       update.accept(extIdNotes);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
diff --git a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
index 2264612..fb9a375 100644
--- a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -57,15 +57,13 @@
     }
 
     @Override
-    public <K, V> com.google.common.cache.Cache<K, V> build(
-        PersistentCacheDef<K, V> def, CacheBackend backend) {
-      return memoryCacheFactory.build(def, backend);
+    public <K, V> com.google.common.cache.Cache<K, V> build(PersistentCacheDef<K, V> def) {
+      return memoryCacheFactory.build(def);
     }
 
     @Override
-    public <K, V> LoadingCache<K, V> build(
-        PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
-      return memoryCacheFactory.build(def, loader, backend);
+    public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader) {
+      return memoryCacheFactory.build(def, loader);
     }
 
     @Override
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 14af43b..16fd4ca 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -45,7 +45,7 @@
 import org.junit.Test;
 
 public class H2CacheTest {
-  private static final TypeLiteral<String> KEY_TYPE = new TypeLiteral<String>() {};
+  private static final TypeLiteral<String> KEY_TYPE = new TypeLiteral<>() {};
   private static final int DEFAULT_VERSION = 1234;
   private static int dbCnt;
 
diff --git a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
index 9b504a9..5958465 100644
--- a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
+++ b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
@@ -21,7 +21,6 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.Weigher;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
@@ -67,7 +66,7 @@
   @Test
   public void shouldNotBlockEvictionsWhenCacheIsDisabledByDefault() throws Exception {
     LoadingCache<Integer, Integer> disabledCache =
-        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()));
 
     assertCacheEvictionIsNotBlocking(disabledCache);
   }
@@ -76,7 +75,7 @@
   public void shouldNotBlockEvictionsWhenCacheIsDisabledByConfiguration() throws Exception {
     memoryCacheConfig.setInt("cache", TEST_CACHE, "memoryLimit", 0);
     LoadingCache<Integer, Integer> disabledCache =
-        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()));
 
     assertCacheEvictionIsNotBlocking(disabledCache);
   }
@@ -84,7 +83,7 @@
   @Test
   public void shouldBlockEvictionsWhenCacheIsEnabled() throws Exception {
     LoadingCache<Integer, Integer> cache =
-        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()));
 
     ScheduledFuture<Integer> cacheValue =
         executor.schedule(() -> cache.getUnchecked(TEST_CACHE_KEY), 0, TimeUnit.SECONDS);
@@ -99,7 +98,7 @@
   @Test
   public void shouldLoadAllKeysWithDisabledCache() throws Exception {
     LoadingCache<Integer, Integer> disabledCache =
-        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()));
 
     List<Integer> keys = Arrays.asList(1, 2);
     ImmutableMap<Integer, Integer> entries = disabledCache.getAll(keys);
@@ -124,7 +123,7 @@
   }
 
   private CacheLoader<Integer, Integer> newCacheLoader(Function<Integer, Integer> loadFunc) {
-    return new CacheLoader<Integer, Integer>() {
+    return new CacheLoader<>() {
 
       @Override
       public Integer load(Integer n) throws Exception {
@@ -134,6 +133,7 @@
           v = loadFunc.apply(n);
           cacheGetCompleted.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS);
         } catch (TimeoutException | BrokenBarrierException e) {
+          // Just continue
         }
         return v;
       }
@@ -147,7 +147,7 @@
   }
 
   private CacheDef<Integer, Integer> newCacheDef(long maximumWeight) {
-    return new CacheDef<Integer, Integer>() {
+    return new CacheDef<>() {
 
       @Override
       public String name() {
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
index 8f5b215..3d0e508 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.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.cache.serialize;
 
 import static com.google.common.truth.Truth.assertThat;
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 8df9292..d68a5c1 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -16,5 +16,6 @@
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
         "//proto/testing:test_java_proto",
+        "@gson//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
index d7fdfe6..2eae1bf 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
index caf1fbb..0eb4103 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
index 8d301e4..78947a2 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
@@ -34,7 +34,7 @@
           .setOwnerGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
           .setVisibleToAll(false)
           .setGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeef12345678"))
-          .setCreatedOn(TimeUtil.nowTs())
+          .setCreatedOn(TimeUtil.now())
           .setMembers(ImmutableSet.of(Account.id(123), Account.id(321)))
           .setSubgroups(
               ImmutableSet.of(
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index 614dcf0..b759fec 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
+import java.util.Optional;
 import org.junit.Test;
 
 public class LabelTypeSerializerTest {
@@ -30,6 +31,7 @@
               ImmutableList.of(
                   LabelValue.create((short) 0, "no vote"),
                   LabelValue.create((short) 1, "approved")))
+          .setDescription(Optional.of("description"))
           .setCanOverride(!LabelType.DEF_CAN_OVERRIDE)
           .setAllowPostSubmit(!LabelType.DEF_ALLOW_POST_SUBMIT)
           .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
index b39ba57..97d152b 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
index bff0c5d..38a2050 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
new file mode 100644
index 0000000..4705c55
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
@@ -0,0 +1,53 @@
+// 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.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import java.util.Optional;
+import org.junit.Test;
+
+public class SubmitRequirementExpressionResultSerializerTest {
+  private static final SubmitRequirementExpressionResult r1 =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=+2"),
+          Status.PASS,
+          ImmutableList.of("Label:Code-Review=+2"),
+          ImmutableList.of());
+
+  private static final SubmitRequirementExpressionResult r2 =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=+2"),
+          Status.ERROR,
+          ImmutableList.of(),
+          ImmutableList.of(),
+          Optional.of("Failed to parse the code review label"));
+
+  @Test
+  public void roundTrip_withoutError() throws Exception {
+    assertThat(deserialize(serialize(r1))).isEqualTo(r1);
+  }
+
+  @Test
+  public void roundTrip_withErrorMessage() throws Exception {
+    assertThat(deserialize(serialize(r2))).isEqualTo(r2);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
new file mode 100644
index 0000000..7b8db25
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
@@ -0,0 +1,303 @@
+// 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.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.notedb.ChangeNoteJson;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class SubmitRequirementJsonSerializerTest {
+  private static final SubmitRequirementExpression srReqExp =
+      SubmitRequirementExpression.create("label:Code-Review=+2");
+
+  private static final String srReqExpSerial = "{\"expressionString\":\"label:Code-Review=+2\"}";
+
+  private static final SubmitRequirement sr =
+      SubmitRequirement.builder()
+          .setName("CR")
+          .setDescription(Optional.of("CR description"))
+          .setApplicabilityExpression(SubmitRequirementExpression.of("branch:refs/heads/master"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
+          .setAllowOverrideInChildProjects(true)
+          .build();
+
+  private static final String srSerial =
+      "{\"name\":\"CR\","
+          + "\"description\":{\"value\":\"CR description\"},"
+          + "\"applicabilityExpression\":{\"value\":"
+          + "{\"expressionString\":\"branch:refs/heads/master\"}},"
+          + "\"submittabilityExpression\":{"
+          + "\"expressionString\":\"label:Code-Review=+2\"},"
+          + "\"overrideExpression\":{\"value\":null},"
+          + "\"allowOverrideInChildProjects\":true}";
+
+  private static final SubmitRequirementExpressionResult srExpResult =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=MAX AND -label:Code-Review=MIN"),
+          Status.FAIL,
+          /* passingAtoms= */ ImmutableList.of("label:Code-Review=MAX"),
+          /* failingAtoms= */ ImmutableList.of("label:Code-Review=MIN"));
+
+  private static final String srExpResultSerial =
+      "{\"expression\":{\"expressionString\":"
+          + "\"label:Code-Review=MAX AND -label:Code-Review=MIN\"},"
+          + "\"status\":\"FAIL\","
+          + "\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"label:Code-Review=MAX\"],"
+          + "\"failingAtoms\":[\"label:Code-Review=MIN\"]}";
+
+  private static final SubmitRequirementResult srReqResult =
+      SubmitRequirementResult.builder()
+          .submitRequirement(
+              SubmitRequirement.builder()
+                  .setName("CR")
+                  .setDescription(Optional.of("CR Description"))
+                  .setApplicabilityExpression(
+                      SubmitRequirementExpression.of("branch:refs/heads/master"))
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create("label:\"Code-Review=+2\""))
+                  .setOverrideExpression(SubmitRequirementExpression.of("label:Override=+1"))
+                  .setAllowOverrideInChildProjects(false)
+                  .build())
+          .patchSetCommitId(ObjectId.fromString("4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e"))
+          .applicabilityExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      SubmitRequirementExpression.create("branch:refs/heads/master"),
+                      Status.PASS,
+                      ImmutableList.of("refs/heads/master"),
+                      ImmutableList.of())))
+          .submittabilityExpressionResult(
+              SubmitRequirementExpressionResult.create(
+                  SubmitRequirementExpression.create("label:\"Code-Review=+2\""),
+                  Status.PASS,
+                  /* passingAtoms= */ ImmutableList.of("label:\"Code-Review=+2\""),
+                  /* failingAtoms= */ ImmutableList.of()))
+          .overrideExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      SubmitRequirementExpression.create("label:Override=+1"),
+                      Status.PASS,
+                      /* passingAtoms= */ ImmutableList.of(),
+                      /* failingAtoms= */ ImmutableList.of("label:Override=+1"))))
+          .legacy(Optional.of(true))
+          .build();
+
+  private static final String srReqResultSerial =
+      "{\"submitRequirement\":{\"name\":\"CR\",\"description\":{\"value\":\"CR Description\"},"
+          + "\"applicabilityExpression\":{\"value\":{"
+          + "\"expressionString\":\"branch:refs/heads/master\"}},"
+          + "\"submittabilityExpression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+          + "\"overrideExpression\":{\"value\":{\"expressionString\":\"label:Override=+1\"}},"
+          + "\"allowOverrideInChildProjects\":false},"
+          + "\"applicabilityExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"branch:refs/heads/master\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"refs/heads/master\"],"
+          + "\"failingAtoms\":[]}},"
+          + "\"submittabilityExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+          + "\"failingAtoms\":[]}},"
+          + "\"overrideExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"label:Override=+1\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[],"
+          + "\"failingAtoms\":[\"label:Override=+1\"]}},"
+          + "\"patchSetCommitId\":\"4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e\","
+          + "\"legacy\":{\"value\":true},"
+          + "\"forced\":{\"value\":null},"
+          + "\"hidden\":{\"value\":null}}";
+
+  private static final Gson gson = new ChangeNoteJson().getGson();
+
+  @Test
+  public void submitRequirementExpression_serialize() {
+    assertThat(SubmitRequirementExpression.typeAdapter(gson).toJson(srReqExp))
+        .isEqualTo(srReqExpSerial);
+  }
+
+  @Test
+  public void submitRequirementExpression_deserialize() throws Exception {
+    assertThat(SubmitRequirementExpression.typeAdapter(gson).fromJson(srReqExpSerial))
+        .isEqualTo(srReqExp);
+  }
+
+  @Test
+  public void submitRequirementExpression_roundTrip() throws Exception {
+    SubmitRequirementExpression exp = SubmitRequirementExpression.create("label:Code-Review=+2");
+    TypeAdapter<SubmitRequirementExpression> adapter =
+        SubmitRequirementExpression.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(exp))).isEqualTo(exp);
+  }
+
+  @Test
+  public void submitRequirement_serialize() throws Exception {
+    assertThat(SubmitRequirement.typeAdapter(gson).toJson(sr)).isEqualTo(srSerial);
+  }
+
+  @Test
+  public void submitRequirement_deserialize() throws Exception {
+    assertThat(SubmitRequirement.typeAdapter(gson).fromJson(srSerial)).isEqualTo(sr);
+  }
+
+  @Test
+  public void submitRequirement_roundTrip() throws Exception {
+    TypeAdapter<SubmitRequirement> adapter = SubmitRequirement.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(sr))).isEqualTo(sr);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_serialize() throws Exception {
+    assertThat(SubmitRequirementExpressionResult.typeAdapter(gson).toJson(srExpResult))
+        .isEqualTo(srExpResultSerial);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_deserialize() throws Exception {
+    assertThat(SubmitRequirementExpressionResult.typeAdapter(gson).fromJson(srExpResultSerial))
+        .isEqualTo(srExpResult);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_roundtrip() throws Exception {
+    TypeAdapter<SubmitRequirementExpressionResult> adapter =
+        SubmitRequirementExpressionResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srExpResult))).isEqualTo(srExpResult);
+  }
+
+  @Test
+  public void submitRequirementResult_serialize() throws Exception {
+    assertThat(SubmitRequirementResult.typeAdapter(gson).toJson(srReqResult))
+        .isEqualTo(srReqResultSerial);
+  }
+
+  @Test
+  public void submitRequirementResult_deserialize_optionalSubmittabilityExpressionResultField()
+      throws Exception {
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(srReqResultSerial))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserialize_nonOptionalSubmittabilityExpressionResultField()
+      throws Exception {
+    String oldFormatSrReqResultSerial =
+        srReqResultSerial.replace(
+            "\"submittabilityExpressionResult\":{\"value\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]}},",
+            "\"submittabilityExpressionResult\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]},");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(oldFormatSrReqResultSerial))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_roundTrip() throws Exception {
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srReqResult))).isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_withHidden_roundTrip() throws Exception {
+    SubmitRequirementResult srResultWithHidden =
+        srReqResult.toBuilder().hidden(Optional.of(true)).build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResultWithHidden))).isEqualTo(srResultWithHidden);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNoHidden() throws Exception {
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace(",hidden\":{\"value\":null}", "");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNonExistentField() throws Exception {
+    // Tests that unrecognized fields are skipped on deserialization (e.g. when the new fields are
+    // introduced, the old binary can parse the new-format Json)
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace(
+            "\"hidden\":{\"value\":null}}", "\"non-existent\":{\"value\":null}}");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_emptySubmittabilityExpressionResultField_roundTrip()
+      throws Exception {
+    SubmitRequirementResult srResult =
+        srReqResult
+            .toBuilder()
+            .submittabilityExpressionResult(Optional.empty())
+            .applicabilityExpressionResult(Optional.empty())
+            .overrideExpressionResult(Optional.empty())
+            .build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResult))).isEqualTo(srResult);
+  }
+
+  @Test
+  public void deserializeSubmitRequirementResult_withJGitPatchsetIdFormat() throws Exception {
+    String srResultSerialJgitFormat =
+        srReqResultSerial.replace(
+            "\"4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e\"",
+            "{\"w1\":1180937118,\"w2\":-1632331231,\"w3\":1315497487,"
+                + "\"w4\":266719414,\"w5\":-196118978}");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(srResultSerialJgitFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNonOptionalLegacyField() throws Exception {
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace("\"legacy\":{\"value\":true}", "\"legacy\":true");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_emptyLegacyField_roundTrip() throws Exception {
+    SubmitRequirementResult srResult = srReqResult.toBuilder().legacy(Optional.empty()).build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResult))).isEqualTo(srResult);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
index a1dee1a..6993dfe 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -26,7 +26,7 @@
 public class SubmitRequirementSerializerTest {
   private static final SubmitRequirement submitReq =
       SubmitRequirement.builder()
-          .setName("code-review")
+          .setName("Code-Review")
           .setDescription(Optional.of("require code review +2"))
           .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
           .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadTest.java b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
index 08485a4..d0b6c14 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class CommentThreadTest {
@@ -60,7 +60,7 @@
     return new HumanComment(
         new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
index 0c61906..83e8370 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class CommentThreadsTest {
@@ -126,13 +126,15 @@
 
   @Test
   public void branchedThreadsAreFlattenedAccordingToDate() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(3));
     HumanComment sibling1Child =
-        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), Instant.ofEpochMilli(4));
     HumanComment sibling2Child =
-        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), Instant.ofEpochMilli(5));
 
     ImmutableList<HumanComment> comments =
         ImmutableList.of(sibling2, sibling2Child, sibling1, sibling1Child, root);
@@ -146,9 +148,11 @@
 
   @Test
   public void threadsConsiderParentRelationshipStrongerThanDate() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(3));
-    HumanComment child1 = writtenOn(asReply(createComment("child1"), "root"), new Timestamp(2));
-    HumanComment child2 = writtenOn(asReply(createComment("child2"), "child1"), new Timestamp(1));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(3));
+    HumanComment child1 =
+        writtenOn(asReply(createComment("child1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment child2 =
+        writtenOn(asReply(createComment("child2"), "child1"), Instant.ofEpochMilli(1));
 
     ImmutableList<HumanComment> comments = ImmutableList.of(child2, child1, root);
     ImmutableSet<CommentThread<HumanComment>> commentThreads =
@@ -161,9 +165,11 @@
 
   @Test
   public void threadsFallBackToUuidOrderIfParentAndDateAreTheSame() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(2));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(2));
 
     ImmutableList<HumanComment> comments = ImmutableList.of(sibling2, sibling1, root);
     ImmutableSet<CommentThread<HumanComment>> commentThreads =
@@ -224,13 +230,15 @@
 
   @Test
   public void completeThreadWithBranchesCanBeRequestedByReplyToIntermediateComment() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(3));
     HumanComment sibling1Child =
-        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), Instant.ofEpochMilli(4));
     HumanComment sibling2Child =
-        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), Instant.ofEpochMilli(5));
 
     HumanComment reply = asReply(createComment("sibling1"), "root");
 
@@ -262,7 +270,7 @@
     return new HumanComment(
         new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
@@ -274,8 +282,8 @@
     return comment;
   }
 
-  private static HumanComment writtenOn(HumanComment comment, Timestamp writtenOn) {
-    comment.writtenOn = writtenOn;
+  private static HumanComment writtenOn(HumanComment comment, Instant writtenOn) {
+    comment.setWrittenOn(writtenOn);
     return comment;
   }
 
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 8f341aa..b048163 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -100,7 +100,7 @@
   private void configureProject() throws Exception {
     ProjectConfig pc = loadAllProjects();
 
-    for (AccessSection sec : ImmutableList.copyOf(pc.getAccessSections())) {
+    for (AccessSection sec : pc.getAccessSections()) {
       pc.upsertAccessSection(
           sec.getName(),
           updatedSection -> {
@@ -213,7 +213,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(change.currentPatchSetId(), accountId, LabelId.create(label)))
         .value(value)
-        .granted(TimeUtil.nowTs())
+        .granted(TimeUtil.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 97f6e4e..00b92b4 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -27,8 +27,8 @@
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
-import java.sql.Timestamp;
 import org.junit.Test;
 
 public class EventDeserializerTest {
@@ -278,7 +278,7 @@
             Change.id(1000),
             Account.id(1000),
             BranchNameKey.create(Project.nameKey("myproject"), "mybranch"),
-            new Timestamp(System.currentTimeMillis()));
+            TimeUtil.now());
     return change;
   }
 
@@ -335,7 +335,7 @@
     a.commitMessage = "This is a test commit message";
     a.url = "http://somewhere.com";
     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;
     return Suppliers.ofInstance(a);
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 8e4f436..3c9a355 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -607,7 +607,7 @@
         Change.id(CHANGE_NUM),
         Account.id(9999),
         BranchNameKey.create(Project.nameKey(PROJECT), BRANCH),
-        TimeUtil.nowTs());
+        TimeUtil.now());
   }
 
   private <T> Supplier<T> createSupplier(T value) {
@@ -625,7 +625,7 @@
     a.commitMessage = COMMIT_MESSAGE;
     a.url = URL;
     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;
     return Suppliers.ofInstance(a);
diff --git a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
new file mode 100644
index 0000000..96919be
--- /dev/null
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.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.server.extensions.events;
+
+import com.google.gerrit.entities.Project;
+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.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import java.io.IOException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GitReferenceUpdatedTest {
+  private DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
+
+  @Mock GitReferenceUpdatedListener refUpdatedListener;
+  @Mock GitBatchRefUpdateListener batchRefUpdateListener;
+  @Mock EventUtil util;
+  @Mock AccountState updater;
+
+  @Before
+  public void setup() {
+    refUpdatedListeners = new DynamicSet<>();
+    refUpdatedListeners.add("gerrit", refUpdatedListener);
+    batchRefUpdateListeners = new DynamicSet<>();
+    batchRefUpdateListeners.add("gerrit", batchRefUpdateListener);
+  }
+
+  @Test
+  public void RefUpdateEventsAndRefsUpdateEventAreFired_BatchRefUpdate() {
+    BatchRefUpdate update = newBatchRefUpdate();
+    ReceiveCommand cmd1 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/1");
+    ReceiveCommand cmd2 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/meta");
+    cmd1.setResult(ReceiveCommand.Result.OK);
+    cmd2.setResult(ReceiveCommand.Result.OK);
+    update.addCommand(cmd1);
+    update.addCommand(cmd2);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(2)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  @Test
+  public void RefUpdateEventAndRefsUpdateEventAreFired_RefUpdate() throws Exception {
+    String ref = "refs/heads/master";
+    RefUpdate update = newRefUpdate(ref);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(1)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  private static BatchRefUpdate newBatchRefUpdate() {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newBatchUpdate();
+    }
+  }
+
+  private static RefUpdate newRefUpdate(String ref) throws IOException {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newUpdate(ref, false);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 3a8d7e4..29dbe58 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -203,11 +204,14 @@
     return repo.exactRef(refName);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommit(Repository repo, ObjectId treeId, ObjectId parentCommit)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
index 6b8177e..82cc049 100644
--- a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
@@ -16,7 +16,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Project.NameKey;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
@@ -53,7 +53,7 @@
     }
 
     @Override
-    public SortedSet<NameKey> list() {
+    public NavigableSet<NameKey> list() {
       throw new UnsupportedOperationException("Not implemented");
     }
   }
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index 4902830..6c771d7 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -26,7 +26,7 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -77,7 +77,7 @@
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
@@ -105,7 +105,7 @@
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(alternateBasePath.toString());
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
@@ -131,7 +131,7 @@
     createRepository(repoManager.getBasePath(basePathProject), misplacedProject2);
     createRepository(alternateBasePath, misplacedProject1);
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(2);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {altPathProject, basePathProject});
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 690a5cc..6792703 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.Optional;
 import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
@@ -220,9 +221,13 @@
     return u;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private CommitBuilder newCommitBuilder() {
     CommitBuilder cb = new CommitBuilder();
-    PersonIdent author = new PersonIdent("J. Author", "author@example.com", TimeUtil.nowTs(), TZ);
+    PersonIdent author =
+        new PersonIdent("J. Author", "author@example.com", Date.from(TimeUtil.now()), TZ);
     cb.setAuthor(author);
     cb.setCommitter(
         new PersonIdent(
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index e24d481..54407ca 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -30,7 +30,8 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Date;
 import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -59,13 +60,16 @@
   protected Account.Id userId;
   protected PersonIdent userIdent;
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Before
   public void abstractGroupTestSetUp() throws Exception {
     allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
     serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
-    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
     userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
@@ -75,12 +79,15 @@
     allUsersRepo.close();
   }
 
-  protected Timestamp getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
+  protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
     try (RevWalk rw = new RevWalk(allUsersRepo)) {
       Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
       return ref == null
           ? null
-          : new Timestamp(rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().getTime());
+          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().toInstant();
     }
   }
 
@@ -109,8 +116,11 @@
     return md;
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   protected static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
   }
 
   protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
@@ -123,7 +133,7 @@
   }
 
   private static Optional<Account> getAccount(Account.Id id) {
-    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.now());
     account.setFullName("Account " + id);
     return Optional.of(account.build());
   }
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 6ad899e..a764654 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupUuid;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Set;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
@@ -299,7 +299,7 @@
   }
 
   private static AccountGroupMemberAudit createExpMemberAudit(
-      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
+      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Instant addedOn) {
     return AccountGroupMemberAudit.builder()
         .groupId(groupId)
         .memberId(id)
@@ -309,7 +309,7 @@
   }
 
   private static AccountGroupByIdAudit createExpGroupAudit(
-      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
+      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Instant addedOn) {
     return AccountGroupByIdAudit.builder()
         .groupId(groupId)
         .includeUuid(uuid)
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index 1bfa95c..a8f9ff5 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -35,11 +35,12 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneId;
+import java.util.Date;
 import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -246,7 +247,7 @@
   @Test
   public void createdOnDefaultsToNow() throws Exception {
     // Git timestamps are only precise to the second.
-    Timestamp testStart = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+    Instant testStart = TimeUtil.truncateToSecond(TimeUtil.now());
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -262,7 +263,7 @@
 
   @Test
   public void specifiedCreatedOnIsRespectedForNewGroup() throws Exception {
-    Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
+    Instant createdOn = toInstant(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
@@ -607,8 +608,8 @@
 
   @Test
   public void createdOnIsNotAffectedByFurtherUpdates() throws Exception {
-    Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.MAY, 11).atTime(13, 44, 10));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
+    Instant createdOn = toInstant(LocalDate.of(2017, Month.MAY, 11).atTime(13, 44, 10));
+    Instant updatedOn = toInstant(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     GroupDelta initialGroupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
@@ -740,7 +741,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -761,7 +762,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -783,7 +784,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -867,7 +868,7 @@
   public void newCommitIsNotCreatedForPureUpdatedOnUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    Timestamp updatedOn = toTimestamp(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
+    Instant updatedOn = toInstant(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
     GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(updatedOn).build();
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
@@ -1008,7 +1009,7 @@
   @Test
   public void commitTimeMatchesDefaultCreatedOnOfNewGroup() throws Exception {
     // Git timestamps are only precise to the second.
-    long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
+    long testStartAsSecondsSinceEpoch = TimeUtil.now().getEpochSecond();
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1035,7 +1036,7 @@
             .build();
     GroupDelta groupDelta =
         GroupDelta.builder()
-            .setUpdatedOn(new Timestamp(createdOnAsSecondsSinceEpoch * 1000))
+            .setUpdatedOn(Instant.ofEpochSecond(createdOnAsSecondsSinceEpoch))
             .build();
     createGroup(groupCreation, groupDelta);
 
@@ -1043,11 +1044,14 @@
     assertThat(revCommit.getCommitTime()).isEqualTo(createdOnAsSecondsSinceEpoch);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfCommitterMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
-    Timestamp committerTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp createdOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant committerTimestamp =
+        toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant createdOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1064,23 +1068,27 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
+        new PersonIdent(
+            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen()).isEqualTo(createdOn);
+    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
+        .isEqualTo(createdOn.toEpochMilli());
     assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
         .isEqualTo(timeZone.getRawOffset());
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void timestampOfAuthorMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
-    Timestamp authorTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp createdOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant createdOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1097,14 +1105,14 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen()).isEqualTo(createdOn);
+    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(createdOn.toEpochMilli());
     assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
         .isEqualTo(timeZone.getRawOffset());
   }
@@ -1112,7 +1120,7 @@
   @Test
   public void commitTimeMatchesDefaultUpdatedOnOfUpdatedGroup() throws Exception {
     // Git timestamps are only precise to the second.
-    long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
+    long testStartAsSecondsSinceEpoch = TimeUtil.now().getEpochSecond();
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1132,7 +1140,7 @@
     GroupDelta groupDelta =
         GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
+            .setUpdatedOn(Instant.ofEpochSecond(updatedOnAsSecondsSinceEpoch))
             .build();
     updateGroup(groupUuid, groupDelta);
 
@@ -1141,10 +1149,13 @@
   }
 
   @Test
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public void timestampOfCommitterMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
-    Timestamp committerTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant committerTimestamp =
+        toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1156,23 +1167,27 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
+        new PersonIdent(
+            "Jane", "Jane@gerritcodereview.com", Date.from(committerTimestamp), timeZone);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen()).isEqualTo(updatedOn);
+    assertThat(revCommit.getCommitterIdent().getWhen().getTime())
+        .isEqualTo(updatedOn.toEpochMilli());
     assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
         .isEqualTo(timeZone.getRawOffset());
   }
 
   @Test
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public void timestampOfAuthorMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
-    Timestamp authorTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1184,14 +1199,14 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", Date.from(authorTimestamp), timeZone);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen()).isEqualTo(updatedOn);
+    assertThat(revCommit.getAuthorIdent().getWhen().getTime()).isEqualTo(updatedOn.toEpochMilli());
     assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
         .isEqualTo(timeZone.getRawOffset());
   }
@@ -1458,8 +1473,8 @@
                 + "Rename from Old name to New name");
   }
 
-  private static Timestamp toTimestamp(LocalDateTime localDateTime) {
-    return Timestamp.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+  private static Instant toInstant(LocalDateTime localDateTime) {
+    return localDateTime.atZone(ZoneId.systemDefault()).toInstant();
   }
 
   private void populateGroupConfig(AccountGroup.UUID uuid, String fileContent) throws Exception {
@@ -1542,10 +1557,13 @@
     }
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private MetaDataUpdate createMetaDataUpdate() {
     PersonIdent serverIdent =
         new PersonIdent(
-            "Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.nowTs(), timeZone);
+            "Gerrit Server", "noreply@gerritcodereview.com", Date.from(TimeUtil.now()), timeZone);
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
@@ -1567,7 +1585,7 @@
   }
 
   private static Account createAccount(Account.Id id, String name) {
-    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.now());
     account.setFullName(name);
     return account.build();
   }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 3b7beb9..afc56ff 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.List;
 import java.util.Optional;
 import java.util.TimeZone;
@@ -557,8 +558,11 @@
     return GroupReference.create(AccountGroup.uuid(name + "-" + id), name);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, Date.from(TimeUtil.now()), TZ);
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
index 9025691..6745b1d 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -95,7 +96,7 @@
   @Test
   public void groupNameNoteFailToParse() throws Exception {
     updateGroupNamesRef("g-1", "[invalid");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index d16efc3..4d9cb76 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -36,7 +36,7 @@
   @Test
   public void refStateFieldValues() throws Exception {
     AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    Account.Builder account = Account.builder(Account.id(1), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(1), TimeUtil.now());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
     List<String> values =
@@ -51,7 +51,7 @@
   public void externalIdStateFieldValues() throws Exception {
     Account.Id id = Account.id(1);
     Account account =
-        Account.builder(id, TimeUtil.nowTs())
+        Account.builder(id, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     ExternalId extId1 =
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 6ad2060..0bdf5cd 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -27,13 +27,17 @@
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.testing.FakeStoredValue;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -55,17 +59,22 @@
 
   @Test
   public void reviewerFieldValues() {
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
-    Timestamp t1 = TimeUtil.nowTs();
+    Table<ReviewerStateInternal, Account.Id, Instant> t = HashBasedTable.create();
+
+    // Timestamps are stored as epoch millis in the reviewer field. Epoch millis are less precise
+    // than Instants which have nanosecond precision. Create Instants with millisecond precision
+    // here so that the comparison for the assertions works.
+    Instant t1 = Instant.ofEpochMilli(TimeUtil.nowMs());
+    Instant t2 = Instant.ofEpochMilli(TimeUtil.nowMs());
+
     t.put(ReviewerStateInternal.REVIEWER, Account.id(1), t1);
-    Timestamp t2 = TimeUtil.nowTs();
     t.put(ReviewerStateInternal.CC, Account.id(2), t2);
     ReviewerSet reviewers = ReviewerSet.fromTable(t);
 
     List<String> values = ChangeField.getReviewerFieldValues(reviewers);
     assertThat(values)
         .containsExactly(
-            "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
+            "REVIEWER,1", "REVIEWER,1," + t1.toEpochMilli(), "CC,2", "CC,2," + t2.toEpochMilli());
 
     assertThat(ChangeField.parseReviewerFieldValues(Change.id(1), values)).isEqualTo(reviewers);
   }
@@ -84,6 +93,18 @@
   }
 
   @Test
+  public void formatSubmitRequirementValues() {
+    assertThat(
+            ChangeField.formatSubmitRequirementValues(
+                ImmutableList.of(
+                    submitRequirementResult(
+                        "CR", "label:CR=+1", SubmitRequirementExpressionResult.Status.PASS),
+                    submitRequirementResult(
+                        "LC", "label:LC=+1", SubmitRequirementExpressionResult.Status.FAIL))))
+        .containsExactly("MAY,cr", "OK,cr", "NEED,lc", "REJECT,lc");
+  }
+
+  @Test
   public void storedSubmitRecords() {
     assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
 
@@ -147,6 +168,55 @@
     assertThat(ChangeField.DELETED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
+  @Test
+  public void shortStringIsNotTruncated() {
+    assertThat(ChangeField.truncateStringValue("short string", 20)).isEqualTo("short string");
+    String two_byte_str = String.format("short string %s", new String(Character.toChars(956)));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 20)).isEqualTo(two_byte_str);
+    String three_byte_str = String.format("short string %s", new String(Character.toChars(43421)));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 20)).isEqualTo(three_byte_str);
+    String four_byte_str = String.format("short string %s", new String(Character.toChars(132878)));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 20)).isEqualTo(four_byte_str);
+    assertThat(ChangeField.truncateStringValue("", 6)).isEqualTo("");
+    assertThat(ChangeField.truncateStringValue("", 0)).isEqualTo("");
+  }
+
+  @Test
+  public void longStringIsTruncated() {
+    assertThat(ChangeField.truncateStringValue("longer string", 6)).isEqualTo("longer");
+    assertThat(ChangeField.truncateStringValue("longer string", 0)).isEqualTo("");
+
+    String two_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(956)));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 17))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(956))));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 18))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(956))));
+
+    String three_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(43421)));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 17)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 18))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(43421))));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 21))
+        .isEqualTo(String.format("multibytechars %1$s%1$s", new String(Character.toChars(43421))));
+
+    String four_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(132878)));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 17)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 18)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 19))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(132878))));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 23))
+        .isEqualTo(String.format("multibytechars %1$s%1$s", new String(Character.toChars(132878))));
+  }
+
   private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
     SubmitRecord r = new SubmitRecord();
     r.status = status;
@@ -156,6 +226,25 @@
     return r;
   }
 
+  private SubmitRequirementResult submitRequirementResult(
+      String srName, String submitExpr, SubmitRequirementExpressionResult.Status submitExprStatus) {
+    return SubmitRequirementResult.builder()
+        .submitRequirement(
+            SubmitRequirement.builder()
+                .setName(srName)
+                .setSubmittabilityExpression(SubmitRequirementExpression.create("NA"))
+                .setAllowOverrideInChildProjects(false)
+                .build())
+        .submittabilityExpressionResult(
+            SubmitRequirementExpressionResult.create(
+                SubmitRequirementExpression.create(submitExpr),
+                submitExprStatus,
+                ImmutableList.of(submitExpr),
+                ImmutableList.of()))
+        .patchSetCommitId(ObjectId.zeroId())
+        .build();
+  }
+
   private static SubmitRecord.Label label(
       SubmitRecord.Label.Status status, String label, Integer appliedBy) {
     SubmitRecord.Label l = new SubmitRecord.Label();
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 0a4f31c..bcd0add 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -220,12 +220,10 @@
     assertThat(status("file:a")).isEqualTo(all);
     assertThat(status("is:new")).containsExactly(NEW);
     assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
-    assertThat(status("is:new OR is:x")).isEqualTo(all);
 
     assertThat(status("is:new is:merged")).isEmpty();
     assertThat(status("(is:new) (is:merged)")).isEmpty();
     assertThat(status("(is:new) (is:merged)")).isEmpty();
-    assertThat(status("is:new is:x")).containsExactly(NEW);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 66a98e8..e879170 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.index.query.OperatorPredicate;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -25,7 +26,7 @@
 public class FakeQueryBuilder extends ChangeQueryBuilder {
   FakeQueryBuilder(ChangeIndexCollection indexes) {
     super(
-        new ChangeQueryBuilder.Definition<>(FakeQueryBuilder.class),
+        new QueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null,
             null,
@@ -56,6 +57,7 @@
             new Config(),
             null,
             null,
+            null,
             null));
   }
 
@@ -70,6 +72,6 @@
   }
 
   private Predicate<ChangeData> predicate(String name, String value) {
-    return new OperatorPredicate<ChangeData>(name, value) {};
+    return new OperatorPredicate<>(name, value) {};
   }
 }
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index 981e284..b1cd8fb 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -32,8 +32,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
@@ -363,7 +361,7 @@
   }
 
   private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+    private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
     public void log(String operation, long durationMs, Metadata metadata) {
@@ -371,7 +369,7 @@
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
+      return logEntries.build();
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/mail/SignedTokenTest.java b/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
index 41d8d69..27c4f56 100644
--- a/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
+++ b/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
@@ -152,7 +152,7 @@
   private static String randomString(int length) {
     String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
     Random random = new Random();
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     for (int i = 0; i < length; i++) {
       int number = random.nextInt(62);
       sb.append(str.charAt(number));
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 5980071..629b0cc 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -362,7 +362,7 @@
 
   private AccountState makeUser(String name, String email) {
     final Account.Id userId = Account.id(42);
-    final Account.Builder account = Account.builder(userId, TimeUtil.nowTs());
+    final Account.Builder account = Account.builder(userId, TimeUtil.now());
     account.setFullName(name);
     account.setPreferredEmail(email);
     return AccountState.forAccount(account.build());
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
similarity index 89%
rename from javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
rename to javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
index 2ec5e4d..fbeabe1 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
@@ -25,7 +25,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class MailSoySauceProviderTest {
+public class MailSoySauceLoaderTest {
 
   private SitePaths sitePaths;
   private DynamicSet<MailSoyTemplateProvider> set;
@@ -38,11 +38,11 @@
 
   @Test
   public void soyCompilation() {
-    MailSoySauceProvider provider =
-        new MailSoySauceProvider(
+    MailSoySauceLoader loader =
+        new MailSoySauceLoader(
             sitePaths,
             new SoyAstCache(),
             new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
-    assertThat(provider.get()).isNotNull(); // should not throw
+    assertThat(loader.load()).isNotNull(); // should not throw
   }
 }
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
new file mode 100644
index 0000000..3ce60b8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -0,0 +1,74 @@
+// 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.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Names;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.nio.file.Paths;
+import javax.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class MailSoySauceModuleTest {
+  @Test
+  public void soySauceProviderReturnsCachedValue() throws Exception {
+    SitePaths sitePaths = new SitePaths(Paths.get("."));
+    Injector injector =
+        Guice.createInjector(
+            new MailSoySauceModule(),
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                super.configure();
+                bind(ListeningExecutorService.class)
+                    .annotatedWith(CacheRefreshExecutor.class)
+                    .toInstance(newDirectExecutorService());
+                bind(SitePaths.class).toInstance(sitePaths);
+                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(new Config());
+                bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                install(new DefaultMemoryCacheModule());
+              }
+            });
+    Provider<SoySauce> soySauceProvider =
+        injector.getProvider(Key.get(SoySauce.class, MailTemplates.class));
+    LoadingCache<String, SoySauce> cache =
+        injector.getInstance(
+            Key.get(
+                new TypeLiteral<LoadingCache<String, SoySauce>>() {},
+                Names.named(MailSoySauceModule.CACHE_NAME)));
+    assertThat(cache.stats().loadCount()).isEqualTo(0);
+    // Theoretically, this can be flaky, if the delay before the second get takes several seconds.
+    // We assume that tests is fast enough.
+    assertThat(soySauceProvider.get()).isNotNull();
+    assertThat(soySauceProvider.get()).isNotNull();
+    assertThat(cache.stats().loadCount()).isEqualTo(1);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index ba514fd..222be83 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -39,6 +39,8 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -65,7 +67,8 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Date;
 import java.util.TimeZone;
 import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -112,22 +115,26 @@
   protected Injector injector;
   private String systemTimeZone;
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Before
   public void setUpTestEnvironment() throws Exception {
     setTimeForTesting();
 
-    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
+    serverIdent =
+        new PersonIdent("Gerrit Server", "noreply@gerrit.com", Date.from(TimeUtil.now()), TZ);
     project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
     accountCache = new FakeAccountCache();
-    Account.Builder co = Account.builder(Account.id(1), TimeUtil.nowTs());
+    Account.Builder co = Account.builder(Account.id(1), TimeUtil.now());
     co.setFullName("Change Owner");
     co.setPreferredEmail("change@owner.com");
     accountCache.put(co.build());
-    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.nowTs());
+    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.now());
     ou.setFullName("Other Account");
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou.build());
@@ -173,6 +180,8 @@
                         () -> {
                           throw new UnsupportedOperationException();
                         });
+                bind(PatchSetApprovalUuidGenerator.class)
+                    .to(TestPatchSetApprovalUuidGenerator.class);
               }
             });
 
@@ -261,7 +270,7 @@
       int line,
       IdentifiedUser commenter,
       String parentUUID,
-      Timestamp t,
+      Instant t,
       String message,
       short side,
       ObjectId commitId,
@@ -282,11 +291,11 @@
     return c;
   }
 
-  protected static Timestamp truncate(Timestamp ts) {
-    return new Timestamp((ts.getTime() / 1000) * 1000);
+  protected static Instant truncate(Instant ts) {
+    return Instant.ofEpochMilli((ts.toEpochMilli() / 1000) * 1000);
   }
 
-  protected static Timestamp after(Change c, long millis) {
-    return new Timestamp(c.getCreatedOn().getTime() + millis);
+  protected static Instant after(Change c, long millis) {
+    return Instant.ofEpochMilli(c.getCreatedOn().toEpochMilli() + millis);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
new file mode 100644
index 0000000..24e28f3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -0,0 +1,127 @@
+// 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.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gson.Gson;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ChangeNoteJsonTest {
+  private final Gson gson = new ChangeNoteJson().getGson();
+
+  static class Child {
+    Optional<String> optionalValue;
+  }
+
+  static class Parent {
+    Optional<Child> optionalChild;
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeEmptyOptional() {
+    // given
+    Optional<?> empty = Optional.empty();
+
+    // when
+    String json = gson.toJson(empty);
+
+    // then
+    assertThat(json).isEqualTo("{}");
+
+    // and when
+    Optional<?> result = gson.fromJson(json, Optional.class);
+
+    // and then
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNonEmptyOptional() {
+    // given
+    String value = "foo";
+    Optional<String> nonEmpty = Optional.of(value);
+
+    // when
+    String json = gson.toJson(nonEmpty);
+
+    // then
+    assertThat(json).isEqualTo("{\n  \"value\": \"" + value + "\"\n}");
+
+    // and when
+    Optional<String> result = gson.fromJson(json, new TypeLiteral<Optional<String>>() {}.getType());
+
+    // and then
+    assertThat(result).isPresent();
+    assertThat(result.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedNonEmptyOptional() {
+    String value = "foo";
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.of(value);
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {\n"
+                + "        \"value\": \"foo\"\n"
+                + "      }\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isPresent();
+    assertThat(result.optionalChild.get().optionalValue.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedEmptyOptional() {
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.empty();
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {}\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
index f105cf1..666b8fc 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -106,7 +106,7 @@
     ChangeNotes notes = newNotes(change).load();
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     PersonIdent author =
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent);
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent);
     try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
       CommitBuilder cb = new CommitBuilder();
       cb.setParentId(notes.getRevision());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 361932e..c33a87f 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -32,6 +32,11 @@
 import org.junit.Before;
 import org.junit.Test;
 
+/**
+ * Tests for {@link ChangeNotesParser}.
+ *
+ * <p>When modifying storage format, please, add tests that both old and new data can be parsed.
+ */
 public class ChangeNotesParserTest extends AbstractChangeNotesTest {
   private TestRepository<InMemoryRepository> testRepo;
   private ChangeNotesRevWalk walk;
@@ -154,7 +159,6 @@
 
   @Test
   public void parseApprovalWithUUID() throws Exception {
-    // Introduced by https://gerrit-review.googlesource.com/c/gerrit/+/324937
     assertParseSucceeds(
         "Update change\n"
             + "\n"
@@ -165,6 +169,28 @@
             + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
             + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
             + "Subject: This is a test change\n");
+
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: Label1=+1, non-SHA1_UUID\n"
+            + "Label: Label1=+1, non-SHA1_UUID Gerrit User 2 <2@gerrit>\n"
+            + "Label: Label1=0, non-SHA1_UUID Gerrit User 2 <2@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1, \n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1,\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7\n");
+    // UUID for removals is not supported.
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
   }
 
   @Test
@@ -180,15 +206,7 @@
             + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@Gerrit> :\"tag\"\n"
             + "Copied-Label: Label4=+1 Account <1@Gerrit> :\"tag with characters %^#@^( *::!\"\n"
             + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Label: -Label1\n"
-            + "Label: -Label4 Account <1@gerrit>\n"
-            + "Subject: This is a test change\n");
+
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 = 1\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: X+Y\n");
@@ -206,7 +224,7 @@
 
   @Test
   public void parseCopiedApprovalWithUUID() throws Exception {
-    // Introduced by https://gerrit-review.googlesource.com/c/gerrit/+/324937
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
     assertParseSucceeds(
         "Update change\n"
             + "\n"
@@ -221,6 +239,31 @@
             + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
             + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
             + "Subject: This is a test change\n");
+
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Copied-Label: Label2=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>\n"
+            + "Copied-Label: Label1=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
+            + "Copied-Label: Label3=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Subject: This is a test change\n");
+
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
+    assertParseFails(
+        "Copied-Label: Label1=+1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
   }
 
   @Test
@@ -674,7 +717,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent),
         false);
   }
 
@@ -686,7 +729,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent),
         initWorkInProgress);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 61002f9..3295828 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -92,8 +92,8 @@
     cols =
         ChangeColumns.builder()
             .changeKey(Change.key(CHANGE_KEY))
-            .createdOn(new Timestamp(123456L))
-            .lastUpdatedOn(new Timestamp(234567L))
+            .createdOn(Instant.ofEpochMilli(123456L))
+            .lastUpdatedOn(Instant.ofEpochMilli(234567L))
             .owner(Account.id(1000))
             .branch("refs/heads/master")
             .subject("Test change")
@@ -135,7 +135,9 @@
   @Test
   public void serializeCreatedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().createdOn(new Timestamp(98765L)).build()).build(),
+        newBuilder()
+            .columns(cols.toBuilder().createdOn(Instant.ofEpochMilli(98765L)).build())
+            .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -146,7 +148,9 @@
   @Test
   public void serializeLastUpdatedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().lastUpdatedOn(new Timestamp(98765L)).build()).build(),
+        newBuilder()
+            .columns(cols.toBuilder().lastUpdatedOn(Instant.ofEpochMilli(98765L)).build())
+            .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -376,7 +380,7 @@
                     PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .tag("tag")
-            .granted(new Timestamp(1212L))
+            .granted(Instant.ofEpochMilli(1212L))
             .build();
     Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
     ByteString a1Bytes = Protos.toByteString(psa1);
@@ -389,7 +393,7 @@
             .value(-1)
             .tag("tag")
             .copied(true)
-            .granted(new Timestamp(3434L))
+            .granted(Instant.ofEpochMilli(3434L))
             .build();
     Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
     ByteString a2Bytes = Protos.toByteString(psa2);
@@ -410,14 +414,62 @@
   }
 
   @Test
+  public void serializeApprovalsWithUUID() throws Exception {
+    PatchSetApproval a1 =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
+            .value(1)
+            .tag("tag")
+            .granted(Instant.ofEpochMilli(1212L))
+            .build();
+    Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
+    ByteString a1Bytes = Protos.toByteString(psa1);
+
+    PatchSetApproval a2 =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create(LabelId.VERIFIED)))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
+            .value(-1)
+            .tag("tag")
+            .copied(true)
+            .granted(Instant.ofEpochMilli(3434L))
+            .build();
+    Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
+    ByteString a2Bytes = Protos.toByteString(psa2);
+    assertThat(a2Bytes.size()).isEqualTo(98);
+    assertThat(a2Bytes).isNotEqualTo(a1Bytes);
+
+    assertRoundTrip(
+        newBuilder()
+            .approvals(ImmutableListMultimap.of(a2.patchSetId(), a2, a1.patchSetId(), a1).entries())
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .addApproval(psa2)
+            .addApproval(psa1)
+            .build());
+  }
+
+  @Test
   public void serializeReviewers() throws Exception {
     assertRoundTrip(
         newBuilder()
             .reviewers(
                 ReviewerSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
-                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
+                    ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
+                        .put(
+                            ReviewerStateInternal.CC, Account.id(2001), Instant.ofEpochMilli(1212L))
+                        .put(
+                            ReviewerStateInternal.REVIEWER,
+                            Account.id(2002),
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -443,15 +495,15 @@
         newBuilder()
             .reviewersByEmail(
                 ReviewerByEmailSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
+                    ImmutableTable.<ReviewerStateInternal, Address, Instant>builder()
                         .put(
                             ReviewerStateInternal.CC,
                             Address.create("Name1", "email1@example.com"),
-                            new Timestamp(1212L))
+                            Instant.ofEpochMilli(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
                             Address.create("Name2", "email2@example.com"),
-                            new Timestamp(3434L))
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -481,7 +533,7 @@
                         ImmutableTable.of(
                             ReviewerStateInternal.CC,
                             Address.create("emailonly@example.com"),
-                            new Timestamp(1212L))))
+                            Instant.ofEpochMilli(1212L))))
                 .build(),
             ChangeNotesStateProto.newBuilder()
                 .setMetaId(SHA_BYTES)
@@ -509,9 +561,13 @@
         newBuilder()
             .pendingReviewers(
                 ReviewerSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
-                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
+                    ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
+                        .put(
+                            ReviewerStateInternal.CC, Account.id(2001), Instant.ofEpochMilli(1212L))
+                        .put(
+                            ReviewerStateInternal.REVIEWER,
+                            Account.id(2002),
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -537,15 +593,15 @@
         newBuilder()
             .pendingReviewersByEmail(
                 ReviewerByEmailSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
+                    ImmutableTable.<ReviewerStateInternal, Address, Instant>builder()
                         .put(
                             ReviewerStateInternal.CC,
                             Address.create("Name1", "email1@example.com"),
-                            new Timestamp(1212L))
+                            Instant.ofEpochMilli(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
                             Address.create("Name2", "email2@example.com"),
-                            new Timestamp(3434L))
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -585,12 +641,12 @@
             .reviewerUpdates(
                 ImmutableList.of(
                     ReviewerStatusUpdate.create(
-                        new Timestamp(1212L),
+                        Instant.ofEpochMilli(1212L),
                         Account.id(1000),
                         Account.id(2002),
                         ReviewerStateInternal.CC),
                     ReviewerStatusUpdate.create(
-                        new Timestamp(3434L),
+                        Instant.ofEpochMilli(3434L),
                         Account.id(1000),
                         Account.id(2001),
                         ReviewerStateInternal.REVIEWER)))
@@ -696,7 +752,7 @@
                                 .setApplicabilityExpression(
                                     SubmitRequirementExpression.of("project:foo"))
                                 .setSubmittabilityExpression(
-                                    SubmitRequirementExpression.create("label:code-review=+2"))
+                                    SubmitRequirementExpression.create("label:Code-Review=+2"))
                                 .setAllowOverrideInChildProjects(false)
                                 .build())
                         .applicabilityExpressionResult(
@@ -708,10 +764,10 @@
                                     ImmutableList.of())))
                         .submittabilityExpressionResult(
                             SubmitRequirementExpressionResult.create(
-                                SubmitRequirementExpression.create("label:code-review=+2"),
+                                SubmitRequirementExpression.create("label:Code-Review=+2"),
                                 SubmitRequirementExpressionResult.Status.FAIL,
                                 ImmutableList.of(),
-                                ImmutableList.of("label:code-review=+2")))
+                                ImmutableList.of("label:Code-Review=+2")))
                         .build()))
             .build(),
         newProtoBuilder()
@@ -726,7 +782,7 @@
                         SubmitRequirementProto.newBuilder()
                             .setName("Code-Review")
                             .setApplicabilityExpression("project:foo")
-                            .setSubmittabilityExpression("label:code-review=+2")
+                            .setSubmittabilityExpression("label:Code-Review=+2")
                             .setAllowOverrideInChildProjects(false)
                             .build())
                     .setApplicabilityExpressionResult(
@@ -737,9 +793,9 @@
                             .build())
                     .setSubmittabilityExpressionResult(
                         SubmitRequirementExpressionResultProto.newBuilder()
-                            .setExpression("label:code-review=+2")
+                            .setExpression("label:Code-Review=+2")
                             .setStatus("FAIL")
-                            .addFailingAtoms("label:code-review=+2")
+                            .addFailingAtoms("label:Code-Review=+2")
                             .build())
                     .build())
             .build());
@@ -752,9 +808,11 @@
             .assigneeUpdates(
                 ImmutableList.of(
                     AssigneeStatusUpdate.create(
-                        new Timestamp(1212L), Account.id(1000), Optional.of(Account.id(2001))),
+                        Instant.ofEpochMilli(1212L),
+                        Account.id(1000),
+                        Optional.of(Account.id(2001))),
                     AssigneeStatusUpdate.create(
-                        new Timestamp(3434L), Account.id(1000), Optional.empty())))
+                        Instant.ofEpochMilli(3434L), Account.id(1000), Optional.empty())))
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -799,7 +857,7 @@
         ChangeMessage.create(
             ChangeMessage.key(ID, "uuid1"),
             Account.id(1000),
-            new Timestamp(1212L),
+            Instant.ofEpochMilli(1212L),
             PatchSet.id(ID, 1));
     Entities.ChangeMessage m1Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m1);
     ByteString m1Bytes = Protos.toByteString(m1Proto);
@@ -809,7 +867,7 @@
         ChangeMessage.create(
             ChangeMessage.key(ID, "uuid2"),
             Account.id(2000),
-            new Timestamp(3434L),
+            Instant.ofEpochMilli(3434L),
             PatchSet.id(ID, 2));
     Entities.ChangeMessage m2Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m2);
     ByteString m2Bytes = Protos.toByteString(m2Proto);
@@ -833,7 +891,7 @@
         new HumanComment(
             new Comment.Key("uuid1", "file1", 1),
             Account.id(1001),
-            new Timestamp(1212L),
+            Instant.ofEpochMilli(1212L),
             (short) 1,
             "message 1",
             "serverId",
@@ -845,7 +903,7 @@
         new HumanComment(
             new Comment.Key("uuid2", "file2", 2),
             Account.id(1002),
-            new Timestamp(3434L),
+            Instant.ofEpochMilli(3434L),
             (short) 2,
             "message 2",
             "serverId",
@@ -921,7 +979,7 @@
                     "submitRequirementsResult",
                     new TypeLiteral<ImmutableList<SubmitRequirementResult>>() {}.getType())
                 .put("updateCount", int.class)
-                .put("mergedOn", Timestamp.class)
+                .put("mergedOn", Instant.class)
                 .build());
   }
 
@@ -931,8 +989,8 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("changeKey", Change.Key.class)
-                .put("createdOn", Timestamp.class)
-                .put("lastUpdatedOn", Timestamp.class)
+                .put("createdOn", Instant.class)
+                .put("lastUpdatedOn", Instant.class)
                 .put("owner", Account.Id.class)
                 .put("branch", String.class)
                 .put("currentPatchSetId", PatchSet.Id.class)
@@ -958,7 +1016,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
-                .put("createdOn", Timestamp.class)
+                .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
@@ -978,8 +1036,9 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
+                .put("uuid", new TypeLiteral<Optional<PatchSetApproval.UUID>>() {}.getType())
                 .put("value", short.class)
-                .put("granted", Timestamp.class)
+                .put("granted", Instant.class)
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
@@ -995,7 +1054,7 @@
             ImmutableMap.of(
                 "table",
                 new TypeLiteral<
-                    ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>>() {}.getType(),
+                    ImmutableTable<ReviewerStateInternal, Account.Id, Instant>>() {}.getType(),
                 "accounts",
                 new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType()));
   }
@@ -1007,7 +1066,7 @@
             ImmutableMap.of(
                 "table",
                 new TypeLiteral<
-                    ImmutableTable<ReviewerStateInternal, Address, Timestamp>>() {}.getType(),
+                    ImmutableTable<ReviewerStateInternal, Address, Instant>>() {}.getType(),
                 "users",
                 new TypeLiteral<ImmutableSet<Address>>() {}.getType()));
   }
@@ -1017,7 +1076,7 @@
     assertThatSerializedClass(ReviewerStatusUpdate.class)
         .hasAutoValueMethods(
             ImmutableMap.of(
-                "date", Timestamp.class,
+                "date", Instant.class,
                 "updatedBy", Account.Id.class,
                 "reviewer", Account.Id.class,
                 "state", ReviewerStateInternal.class));
@@ -1029,7 +1088,7 @@
         .hasAutoValueMethods(
             ImmutableMap.of(
                 "date",
-                Timestamp.class,
+                Instant.class,
                 "updatedBy",
                 Account.Id.class,
                 "currentAssignee",
@@ -1067,7 +1126,7 @@
   @Test
   public void serializeMergedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().mergedOn(new Timestamp(234567L)).build(),
+        newBuilder().mergedOn(Instant.ofEpochMilli(234567L)).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -1086,7 +1145,7 @@
             ImmutableMap.<String, Type>builder()
                 .put("key", ChangeMessage.Key.class)
                 .put("author", Account.Id.class)
-                .put("writtenOn", Timestamp.class)
+                .put("writtenOn", Instant.class)
                 .put("message", String.class)
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index c524c94..09c8059 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -61,7 +62,6 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -131,7 +131,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -193,7 +193,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -244,12 +244,14 @@
     assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
+    assertParsedUuid(psas.get(0));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(1);
     assertThat(psas.get(1).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
+    assertParsedUuid(psas.get(1));
   }
 
   @Test
@@ -276,6 +278,7 @@
     assertThat(psa1.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa1.value()).isEqualTo((short) -1);
     assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
+    assertParsedUuid(psa1);
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
     assertThat(psa2.patchSetId()).isEqualTo(ps2);
@@ -283,6 +286,7 @@
     assertThat(psa2.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa2.value()).isEqualTo((short) +1);
     assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
+    assertParsedUuid(psa2);
   }
 
   @Test
@@ -297,6 +301,7 @@
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.putApproval(LabelId.CODE_REVIEW, (short) 1);
@@ -306,6 +311,7 @@
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
   }
 
   @Test
@@ -329,12 +335,14 @@
     assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
+    assertParsedUuid(psas.get(0));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(2);
     assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
+    assertParsedUuid(psas.get(1));
   }
 
   @Test
@@ -350,6 +358,7 @@
     assertThat(psa.accountId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.removeApproval("Not-For-Long");
@@ -368,6 +377,248 @@
   }
 
   @Test
+  public void approval_UUIDGenerated_forAllValues() throws Exception {
+    for (int value = -2; value <= 2; value++) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) value);
+      update.commit();
+
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval psa =
+          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+      assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(psa.value()).isEqualTo((short) value);
+      assertParsedUuid(psa);
+    }
+  }
+
+  @Test
+  public void emptyApproval_uuidGenerated() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 0);
+    update.commit();
+
+    notes = newNotes(c);
+    PatchSetApproval emptyPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+    assertThat(emptyPsa.key()).isEqualTo(psa.key());
+    assertThat(emptyPsa.value()).isEqualTo((short) 0);
+    assertThat(emptyPsa.label()).isEqualTo(psa.label());
+    assertParsedUuid(emptyPsa);
+  }
+
+  @Test
+  public void removedApproval_noUuid() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
+
+    update = newUpdate(c, changeOwner);
+    update.removeApproval(LabelId.CODE_REVIEW);
+    update.commit();
+
+    notes = newNotes(c);
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(psa.key());
+    assertThat(removedPsa.value()).isEqualTo((short) 0);
+    assertThat(removedPsa.label()).isEqualTo(psa.label());
+    assertThat(removedPsa.uuid()).isEmpty();
+  }
+
+  @Test
+  public void reissuedApproval_samePatchSet_differentUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+
+    assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo((short) -1);
+    assertParsedUuid(originalPsa);
+
+    // Remove approval from current patch set
+    update = newUpdate(c, changeOwner);
+    update.removeApproval(LabelId.CODE_REVIEW);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(originalPsa.key());
+    assertThat(removedPsa.value()).isEqualTo(0);
+    // Add approval with the same author, label, value to the current patch set
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    PatchSetApproval reAddedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+
+    assertThat(reAddedPsa.key()).isEqualTo(originalPsa.key());
+    assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
+    // The re-added approval has a different UUID
+    assertParsedUuid(reAddedPsa);
+    assertThat(reAddedPsa.uuid().get()).isNotEqualTo(originalPsa.uuid().get());
+  }
+
+  @Test
+  public void reissuedApproval_otherPatchSet_differentUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+
+    assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(originalPsa.accountId().get()).isEqualTo(1);
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo((short) -1);
+    assertParsedUuid(originalPsa);
+
+    // Create new PatchSet and re-issue vote
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).hasSize(2);
+    PatchSetApproval postUpdateOriginalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(originalPsa.patchSetId()));
+    PatchSetApproval reAddedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+
+    // Same patch set approval for the original patch set is returned after the vote was re-issued
+    // on the next patch set
+    assertThat(postUpdateOriginalPsa).isEqualTo(originalPsa);
+
+    assertThat(reAddedPsa.accountId()).isEqualTo(originalPsa.accountId());
+    assertThat(reAddedPsa.label()).isEqualTo(originalPsa.label());
+    assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
+
+    // The re-added approval has a different UUID
+    assertParsedUuid(reAddedPsa);
+    assertThat(reAddedPsa.uuid().get()).isNotEqualTo(originalPsa.uuid().get());
+  }
+
+  @Test
+  public void approvalUUID_samePatchSet_differentUsers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(patchSetApprovals).hasSize(2);
+    assertThat(
+            patchSetApprovals.stream()
+                .filter(psa -> psa.value() == (short) -1 && psa.label().equals(LabelId.CODE_REVIEW))
+                .count())
+        .isEqualTo(2);
+    // Count UUIDs to make sure they are unique
+    assertThat(patchSetApprovals.stream().map(psa -> psa.uuid().get()).distinct().count())
+        .isEqualTo(2);
+    assertThat(patchSetApprovals.stream().map(psa -> psa.accountId()).collect(toImmutableSet()))
+        .containsExactly(changeOwner.getAccountId(), otherUserId);
+  }
+
+  @Test
+  public void approvalUUID_samePatchSet_differentLabels() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(patchSetApprovals).hasSize(2);
+    assertThat(
+            patchSetApprovals.stream()
+                .filter(
+                    psa ->
+                        psa.value() == (short) -1
+                            && psa.accountId().equals(changeOwner.getAccountId()))
+                .count())
+        .isEqualTo(2);
+
+    // Count UUIDs to make sure they are unique
+    assertThat(patchSetApprovals.stream().map(psa -> psa.uuid().get()).distinct().count())
+        .isEqualTo(2);
+    assertThat(patchSetApprovals.stream().map(psa -> psa.label()).collect(toImmutableSet()))
+        .containsExactly(LabelId.VERIFIED, LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void approvalUUID_differentChanges() throws Exception {
+    Change c1 = newChange();
+    ChangeUpdate update1 = newUpdate(c1, changeOwner);
+    update1.putApproval(LabelId.CODE_REVIEW, (short) +2);
+    update1.commit();
+
+    Change c2 = newChange();
+    ChangeUpdate update = newUpdate(c2, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) +2);
+    update.commit();
+
+    ChangeNotes notes1 = newNotes(c1);
+    PatchSetApproval psa1 =
+        Iterables.getOnlyElement(notes1.getApprovals().get(c1.currentPatchSetId()));
+    ChangeNotes notes2 = newNotes(c2);
+    PatchSetApproval psa2 =
+        Iterables.getOnlyElement(notes2.getApprovals().get(c2.currentPatchSetId()));
+    assertThat(psa1.label()).isEqualTo(psa2.label());
+    assertThat(psa1.accountId()).isEqualTo(psa2.accountId());
+    assertThat(psa1.value()).isEqualTo(psa2.value());
+
+    // UUID is global: different across changes.
+    assertThat(psa1.uuid()).isNotEqualTo(psa2.uuid());
+  }
+
+  @Test
   public void removeOtherUsersApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
@@ -380,21 +631,19 @@
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.removeApprovalFor(otherUserId, "Not-For-Long");
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                psa.patchSetId(),
-                PatchSetApproval.builder()
-                    .key(psa.key())
-                    .value(0)
-                    .granted(update.getWhen())
-                    .build()));
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(psa.key());
+    assertThat(removedPsa.value()).isEqualTo((short) 0);
+    assertThat(removedPsa.label()).isEqualTo(psa.label());
+    assertThat(removedPsa.uuid()).isEmpty();
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -406,6 +655,7 @@
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 2);
+    assertParsedUuid(psa);
   }
 
   @Test
@@ -426,10 +676,13 @@
     assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
+    assertParsedUuid(approvals.get(0));
 
     assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) -1);
+    assertThat(approvals.get(1).uuid()).isPresent();
+    assertParsedUuid(approvals.get(1));
   }
 
   @Test
@@ -462,9 +715,12 @@
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertParsedUuid(approvals.get(1));
+
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) 2);
     assertThat(approvals.get(1).postSubmit()).isTrue();
+    assertParsedUuid(approvals.get(1));
   }
 
   @Test
@@ -503,14 +759,154 @@
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo(1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertParsedUuid(approvals.get(0));
+
     assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo(2);
     assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
+    assertParsedUuid(approvals.get(1));
+
     assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
     assertThat(approvals.get(2).label()).isEqualTo("Other-Label");
     assertThat(approvals.get(2).value()).isEqualTo(2);
     assertThat(approvals.get(2).postSubmit()).isTrue();
+    assertParsedUuid(approvals.get(2));
+  }
+
+  @Test
+  public void copiedApprovals_keepsUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo(2);
+    assertThat(originalPsa.tag()).isEmpty();
+    assertThat(originalPsa.realAccountId()).isEqualTo(changeOwner.getAccountId());
+    assertParsedUuid(originalPsa);
+
+    // Copied approvals are persisted at the patch set upload, add new patch set
+    incrementPatchSet(c);
+
+    addCopiedApproval(c, changeOwner, originalPsa);
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+    PatchSetApproval copiedApproval =
+        Iterables.getOnlyElement(
+            notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+                .filter(a -> a.copied())
+                .collect(toImmutableList()));
+    PatchSetApproval nonCopiedApproval =
+        Iterables.getOnlyElement(
+            notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+                .filter(a -> !a.copied())
+                .collect(toImmutableList()));
+
+    // Still same original PSA is returned
+    assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+    // The copied approval matches the original approval, including UUID
+    assertCopiedApproval(originalPsa, copiedApproval);
+  }
+
+  @Test
+  public void copiedApprovals_withRealUserAndTag_keepsUUID() throws Exception {
+    ImmutableList<String> strangeTags =
+        ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
+    for (String strangeTag : strangeTags) {
+      Change c = newChange();
+      CurrentUser otherUserAsOwner = userFactory.runAs(null, changeOwner.getAccountId(), otherUser);
+      ChangeUpdate update = newUpdate(c, otherUserAsOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+      update.setTag(strangeTag);
+      update.commit();
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval originalPsa =
+          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+      assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(originalPsa.value()).isEqualTo(2);
+      assertThat(originalPsa.tag()).hasValue(NoteDbUtil.sanitizeFooter(strangeTag));
+      assertThat(originalPsa.realAccountId()).isEqualTo(otherUserId);
+      assertParsedUuid(originalPsa);
+
+      // Copied approvals are persisted at the patch set upload, add new patch set
+      incrementPatchSet(c);
+
+      addCopiedApproval(c, changeOwner, originalPsa);
+
+      notes = newNotes(c);
+      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      PatchSetApproval copiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+                  .filter(a -> a.copied())
+                  .collect(toImmutableList()));
+      PatchSetApproval nonCopiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+                  .filter(a -> !a.copied())
+                  .collect(toImmutableList()));
+
+      // Still same original PSA is returned
+      assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+      // The copied approval matches the original approval, including UUID
+      assertCopiedApproval(originalPsa, copiedApproval);
+    }
+  }
+
+  @Test
+  public void copiedApprovals_withTag_keepsUUID() throws Exception {
+    ImmutableList<String> strangeTags =
+        ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
+    for (String strangeTag : strangeTags) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+      update.setTag(strangeTag);
+      update.commit();
+
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval originalPsa =
+          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+      assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(originalPsa.value()).isEqualTo(2);
+      assertThat(originalPsa.tag()).hasValue(NoteDbUtil.sanitizeFooter(strangeTag));
+      assertThat(originalPsa.realAccountId()).isEqualTo(changeOwner.getAccountId());
+      assertParsedUuid(originalPsa);
+      // Copied approvals are persisted at the patch set upload, add new patch set
+      incrementPatchSet(c);
+
+      addCopiedApproval(c, changeOwner, originalPsa);
+
+      notes = newNotes(c);
+      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      PatchSetApproval copiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+                  .filter(a -> a.copied())
+                  .collect(toImmutableList()));
+      PatchSetApproval nonCopiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+                  .filter(a -> !a.copied())
+                  .collect(toImmutableList()));
+
+      // Still same original PSA is returned
+      assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+      // The copied approval matches the original approval, including UUID
+      assertCopiedApproval(originalPsa, copiedApproval);
+    }
   }
 
   @Test
@@ -526,7 +922,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .tag("tag")
             .realAccountId(otherUserId)
             .build());
@@ -578,7 +974,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -611,7 +1007,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .tag(strangeTag)
             .build());
     update.commit();
@@ -640,7 +1036,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.putCopiedApproval(
         PatchSetApproval.builder()
@@ -651,7 +1047,7 @@
                     LabelId.create(LabelId.VERIFIED)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -676,7 +1072,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(2)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -700,11 +1096,11 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(
             ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
                     .put(REVIEWER, Account.id(1), ts)
                     .put(REVIEWER, Account.id(2), ts)
                     .build()));
@@ -719,11 +1115,11 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(
             ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
                     .put(REVIEWER, Account.id(1), ts)
                     .put(CC, Account.id(2), ts)
                     .build()));
@@ -737,7 +1133,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, Account.id(2), ts)));
 
@@ -746,7 +1142,7 @@
     update.commit();
 
     notes = newNotes(c);
-    ts = new Timestamp(update.getWhen().getTime());
+    ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, Account.id(2), ts)));
   }
@@ -881,7 +1277,7 @@
     update.commit();
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getMergedOn()).isPresent();
-    Timestamp mergedOn = notes.getMergedOn().get();
+    Instant mergedOn = notes.getMergedOn().get();
     assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // Next update does not change mergedOn date.
@@ -907,7 +1303,7 @@
     update.commit();
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getMergedOn()).isPresent();
-    Timestamp mergedOn = notes.getMergedOn().get();
+    Instant mergedOn = notes.getMergedOn().get();
     assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     incrementPatchSet(c);
@@ -1301,7 +1697,7 @@
   public void createdOnChangeNotes() throws Exception {
     Change c = newChange();
 
-    Timestamp createdOn = newNotes(c).getChange().getCreatedOn();
+    Instant createdOn = newNotes(c).getChange().getCreatedOn();
     assertThat(createdOn).isNotNull();
 
     // An update doesn't affect the createdOn timestamp.
@@ -1316,54 +1712,54 @@
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts1 = notes.getChange().getLastUpdatedOn();
+    Instant ts1 = notes.getChange().getLastUpdatedOn();
     assertThat(ts1).isEqualTo(notes.getChange().getCreatedOn());
 
     // Various kinds of updates that update the timestamp.
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setTopic("topic"); // Change something to get a new commit.
     update.commit();
-    Timestamp ts2 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts2 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts2).isGreaterThan(ts1);
 
     update = newUpdate(c, changeOwner);
     update.setChangeMessage("Some message");
     update.commit();
-    Timestamp ts3 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts3 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts3).isGreaterThan(ts2);
 
     update = newUpdate(c, changeOwner);
     update.setHashtags(ImmutableSet.of("foo"));
     update.commit();
-    Timestamp ts4 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts4 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts4).isGreaterThan(ts3);
 
     incrementPatchSet(c);
-    Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts5 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts5).isGreaterThan(ts4);
 
     update = newUpdate(c, changeOwner);
     update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
-    Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts6 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts6).isGreaterThan(ts5);
 
     update = newUpdate(c, changeOwner);
     update.setStatus(Change.Status.ABANDONED);
     update.commit();
-    Timestamp ts7 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts7 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts7).isGreaterThan(ts6);
 
     update = newUpdate(c, changeOwner);
     update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.REVIEWER);
     update.commit();
-    Timestamp ts8 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts8 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts8).isGreaterThan(ts7);
 
     update = newUpdate(c, changeOwner);
     update.setGroups(ImmutableList.of("a", "b"));
     update.commit();
-    Timestamp ts9 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts9 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts9).isGreaterThan(ts8);
 
     // Finish off by merging the change.
@@ -1377,7 +1773,7 @@
                 submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
-    Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts10 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts10).isGreaterThan(ts9);
   }
 
@@ -1490,7 +1886,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -1579,7 +1975,7 @@
     // comment on ps2
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
-    Timestamp ts = TimeUtil.nowTs();
+    Instant ts = TimeUtil.now();
     update.putComment(
         HumanComment.Status.PUBLISHED,
         newComment(
@@ -1647,7 +2043,7 @@
     String uuid1 = "uuid1";
     String message1 = "comment 1";
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
+    Instant time1 = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
@@ -1896,7 +2292,7 @@
             0,
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1926,7 +2322,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1956,7 +2352,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1986,7 +2382,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -2013,7 +2409,7 @@
     String message3 = "comment 3";
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     HumanComment comment1 =
@@ -2083,7 +2479,7 @@
     String uuid = "uuid";
     String message = "comment";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
@@ -2113,7 +2509,7 @@
 
   @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
-    Account.Builder account = Account.builder(Account.id(3), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(3), TimeUtil.now());
     account.setFullName("Weird\n\u0002<User>\n");
     account.setPreferredEmail(" we\r\nird@ex>ample<.com");
     accountCache.put(account.build());
@@ -2123,7 +2519,7 @@
     ChangeUpdate update = newUpdate(c, user);
     String uuid = "uuid";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2161,7 +2557,7 @@
     String messageForBase = "comment for base";
     String messageForPS = "comment for ps";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment commentForBase =
@@ -2220,8 +2616,8 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp timeForComment1 = TimeUtil.nowTs();
-    Timestamp timeForComment2 = TimeUtil.nowTs();
+    Instant timeForComment1 = TimeUtil.now();
+    Instant timeForComment2 = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             psId,
@@ -2279,7 +2675,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             psId,
@@ -2337,7 +2733,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2360,7 +2756,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
+    now = TimeUtil.now();
     HumanComment comment2 =
         newComment(
             ps2,
@@ -2397,7 +2793,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2442,7 +2838,7 @@
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
     short side = (short) 1;
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     // Write two drafts on the same side of one patch set.
@@ -2512,7 +2908,7 @@
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     // Write two drafts, one on each side of the patchset.
@@ -2587,7 +2983,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment =
         newComment(
             psId,
@@ -2632,7 +3028,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2655,7 +3051,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
+    now = TimeUtil.now();
     HumanComment comment2 =
         newComment(
             ps2,
@@ -2700,7 +3096,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment =
         newComment(
             ps1,
@@ -2733,7 +3129,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment draft =
         newComment(
             ps1,
@@ -2783,7 +3179,7 @@
     String uuid = "uuid";
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2815,7 +3211,7 @@
     String uuid = "uuid";
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2856,7 +3252,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2914,7 +3310,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2988,7 +3384,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -3075,7 +3471,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            new Timestamp(update1.getWhen().getTime()),
+            update1.getWhen(),
             "comment 1",
             (short) 1,
             commitId,
@@ -3092,7 +3488,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            new Timestamp(update2.getWhen().getTime()),
+            update2.getWhen(),
             "comment 2",
             (short) 1,
             commitId,
@@ -3149,7 +3545,7 @@
             range.getEndLine(),
             changeOwner,
             null,
-            new Timestamp(update.getWhen().getTime()),
+            update.getWhen(),
             "comment",
             (short) 1,
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
@@ -3585,11 +3981,45 @@
   }
 
   private AttentionSetUpdate addTimestamp(AttentionSetUpdate attentionSetUpdate, Change c) {
-    Timestamp timestamp = newNotes(c).getChange().getLastUpdatedOn();
+    Instant timestamp = newNotes(c).getChange().getLastUpdatedOn();
     return AttentionSetUpdate.createFromRead(
-        timestamp.toInstant(),
+        timestamp,
         attentionSetUpdate.account(),
         attentionSetUpdate.operation(),
         attentionSetUpdate.reason());
   }
+
+  /**
+   * Assert UUID was parsed as generated by {@link
+   * com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator}.
+   */
+  private void assertParsedUuid(PatchSetApproval patchSetApproval) {
+    assertThat(patchSetApproval.uuid().get().get()).matches("^[0-9a-z_]+$");
+  }
+
+  private void assertCopiedApproval(PatchSetApproval originalPsa, PatchSetApproval copiedPsa) {
+    assertThat(copiedPsa.label()).isEqualTo(originalPsa.label());
+    assertThat(copiedPsa.value()).isEqualTo(originalPsa.value());
+    assertThat(copiedPsa.tag()).isEqualTo(originalPsa.tag());
+    assertThat(copiedPsa.accountId()).isEqualTo(originalPsa.accountId());
+    assertThat(copiedPsa.realAccountId()).isEqualTo(originalPsa.realAccountId());
+    assertThat(copiedPsa.uuid()).isEqualTo(originalPsa.uuid());
+    assertThat(copiedPsa.copied()).isTrue();
+  }
+
+  private void addCopiedApproval(Change c, CurrentUser user, PatchSetApproval originalPsa)
+      throws Exception {
+    ChangeUpdate update = newUpdate(c, user);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(originalPsa.key())
+            .value(originalPsa.value())
+            .copied(true)
+            .granted(TimeUtil.now())
+            .tag(originalPsa.tag())
+            .uuid(originalPsa.uuid())
+            .realAccountId(originalPsa.realAccountId())
+            .build());
+    update.commit();
+  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
index fa05adc..cf1b5ae 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -81,7 +81,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 2c1348c..2191f00 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -34,7 +34,7 @@
   /** Arbitrary time outside of a DST transition, as an ISO instant. */
   private static final String NON_DST_STR = "2017-02-07T10:20:30.123Z";
 
-  /** Arbitrary time outside of a DST transition, as a reasonable Java 8 representation. */
+  /** Arbitrary time outside of a DST transition, as a reasonable Java 11 representation. */
   private static final ZonedDateTime NON_DST = ZonedDateTime.parse(NON_DST_STR);
 
   /** {@link #NON_DST_STR} truncated to seconds. */
@@ -155,7 +155,7 @@
         new HumanComment(
             new Comment.Key("uuid", "filename", 1),
             Account.id(100),
-            NON_DST_TS,
+            NON_DST_TS.toInstant(),
             (short) 0,
             "message",
             "serverId",
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 68a1d9d..5e2e1f2 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import java.util.Date;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -36,6 +35,9 @@
 import org.junit.Test;
 
 public class CommitMessageOutputTest extends AbstractChangeNotesTest {
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
@@ -61,20 +63,20 @@
             + "\n"
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
-            + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n",
+            + "Label: Code-Review=-1, 1_1_1_code_review__1_1\n"
+            + "Label: Verified=+1, 1_1_1_verified_1_2\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
+    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
     assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
+    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
     assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
   }
 
@@ -143,6 +145,9 @@
   }
 
   @Test
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   public void submitCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -184,20 +189,20 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
+    assertThat(author.getWhen().getTime()).isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
     assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
+    assertThat(committer.getWhen().getTime()).isEqualTo(author.getWhen().getTime());
     assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
   }
 
   @Test
   public void anonymousUser() throws Exception {
     Account anon =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     accountCache.put(anon);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 98721fd..3b18183 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -47,10 +47,13 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.IntStream;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
@@ -261,6 +264,7 @@
       Ref metaRefBeforeRewrite = repo.exactRef(refName);
       expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
     }
+    Set<String> invalidRefs = new HashSet<>();
     for (int i = 0; i < numberOfInvalidChanges; i++) {
       Change c = newChange();
       ChangeUpdate update = newUpdate(c, changeOwner);
@@ -270,12 +274,20 @@
       updateWithSubject.setSubjectForCommit("Update with subject");
       updateWithSubject.commit();
       String refName = RefNames.changeMetaRef(c.getId());
-      Ref metaRefBeforeRewrite = repo.exactRef(refName);
-      if (i < maxRefsToUpdate) {
-        expectedFixedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
-      } else {
-        expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+      invalidRefs.add(refName);
+    }
+    int i = 0;
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+      Ref metaRefBeforeRewrite = repo.exactRef(ref.getName());
+      if (!invalidRefs.contains(ref.getName())) {
+        continue;
       }
+      if (i < maxRefsToUpdate) {
+        expectedFixedRefsToOldMetaBuilder.put(ref.getName(), metaRefBeforeRewrite.getObjectId());
+      } else {
+        expectedSkippedRefsToOldMetaBuilder.put(ref.getName(), metaRefBeforeRewrite.getObjectId());
+      }
+      i++;
     }
     ImmutableMap<String, ObjectId> expectedFixedRefsToOldMeta =
         expectedFixedRefsToOldMetaBuilder.build();
@@ -309,15 +321,18 @@
     assertThat(secondRunResult.fixedRefDiff.keySet().size()).isEqualTo(expectedSecondRunResult);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAuthorIdent() throws Exception {
     Change c = newChange();
-    Timestamp when = TimeUtil.nowTs();
+    Instant when = TimeUtil.now();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            when,
+            Date.from(when),
             serverIdent.getTimeZone());
     RevCommit invalidUpdateCommit =
         writeUpdate(
@@ -359,7 +374,8 @@
     assertThat(fixedUpdateCommit.getAuthorIdent().getName())
         .isEqualTo("Gerrit User " + changeOwner.getAccountId());
     assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
-    assertThat(originalAuthorIdent.getWhen()).isEqualTo(fixedAuthorIdent.getWhen());
+    assertThat(originalAuthorIdent.getWhen().getTime())
+        .isEqualTo(fixedAuthorIdent.getWhen().getTime());
     assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
     assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
     assertThat(invalidUpdateCommit.getCommitterIdent())
@@ -437,6 +453,9 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerFooterIdent() throws Exception {
     Change c = newChange();
@@ -483,7 +502,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhen().toInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -520,6 +539,9 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixReviewerMessage() throws Exception {
     Change c = newChange();
@@ -567,21 +589,15 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhen().toInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
-                new Timestamp(addReviewerUpdate.when.getTime()),
-                changeOwner.getAccountId(),
-                otherUserId,
-                REVIEWER),
+                addReviewerUpdate.when, changeOwner.getAccountId(), otherUserId, REVIEWER),
             ReviewerStatusUpdate.create(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED),
             ReviewerStatusUpdate.create(
-                new Timestamp(addCcUpdate.when.getTime()),
-                changeOwner.getAccountId(),
-                otherUserId,
-                CC),
+                addCcUpdate.when, changeOwner.getAccountId(), otherUserId, CC),
             ReviewerStatusUpdate.create(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
     ChangeNotes notesAfterRewrite = newNotes(c);
@@ -653,6 +669,9 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixLabelFooterIdent() throws Exception {
     Change c = newChange();
@@ -703,7 +722,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhen().toInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -787,6 +806,9 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessage() throws Exception {
     Change c = newChange();
@@ -840,7 +862,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhen().toInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -910,6 +932,9 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixRemoveVoteChangeMessageWithUnparsableAuthorIdent() throws Exception {
     Change c = newChange();
@@ -917,7 +942,7 @@
         new PersonIdent(
             changeOwner.getName(),
             "server@" + serverId,
-            TimeUtil.nowTs(),
+            Date.from(TimeUtil.now()),
             serverIdent.getTimeZone());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
@@ -1053,7 +1078,7 @@
   public void fixRemoveVoteChangeMessageWithNoFooterLabel_matchDuplicateAccounts()
       throws Exception {
     Account duplicateCodeOwner =
-        Account.builder(Account.id(4), TimeUtil.nowTs())
+        Account.builder(Account.id(4), TimeUtil.now())
             .setFullName(changeOwner.getName())
             .setPreferredEmail("other@test.com")
             .build();
@@ -1163,6 +1188,9 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixAttentionFooter() throws Exception {
     Change c = newChange();
@@ -1243,46 +1271,46 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
     notesBeforeRewrite.getAttentionSetUpdates();
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhen().toInstant();
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
-                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                invalidRemovedByClickUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 String.format("Removed by %s by clicking the attention icon", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Added by someone"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 String.format("%s replied on the change", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 String.format("Removed by %s using the hovercard menu", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 String.format("%s replied on the change", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                invalidAttentionSetUpdate.getWhen().toInstant(),
+                invalidAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.ADD,
                 String.format("Added by %s using the hovercard menu", otherUser.getName())));
@@ -1290,42 +1318,42 @@
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesAfterRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
-                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                invalidRemovedByClickUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 "Removed by someone by clicking the attention icon"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Added by someone"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 "Someone replied on the change"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Someone replied on the change"),
             AttentionSetUpdate.createFromRead(
-                invalidAttentionSetUpdate.getWhen().toInstant(),
+                invalidAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"));
@@ -1420,22 +1448,22 @@
       thirdAttentionSetUpdate.commit();
       attentionSetUpdatesBeforeRewrite.add(
           AttentionSetUpdate.createFromRead(
-              thirdAttentionSetUpdate.getWhen().toInstant(),
+              thirdAttentionSetUpdate.getWhen(),
               changeOwner.getAccountId(),
               Operation.REMOVE,
               String.format("Removed by %s by clicking the attention icon", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              secondAttentionSetUpdate.getWhen().toInstant(),
+              secondAttentionSetUpdate.getWhen(),
               otherUserId,
               Operation.REMOVE,
               String.format("Removed by %s using the hovercard menu", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              secondAttentionSetUpdate.getWhen().toInstant(),
+              secondAttentionSetUpdate.getWhen(),
               changeOwner.getAccountId(),
               Operation.ADD,
               String.format("%s replied on the change", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              firstAttentionSetUpdate.getWhen().toInstant(),
+              firstAttentionSetUpdate.getWhen(),
               otherUserId,
               Operation.ADD,
               String.format("Added by %s using the hovercard menu", okAccountName)));
@@ -1541,6 +1569,9 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void fixSubmitChangeMessageAndFooters() throws Exception {
     Change c = newChange();
@@ -1548,7 +1579,7 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            TimeUtil.nowTs(),
+            Date.from(TimeUtil.now()),
             serverIdent.getTimeZone());
     String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
     writeUpdate(
@@ -1744,16 +1775,16 @@
   public void fixCodeOwnersOnAddReviewerChangeMessage() throws Exception {
 
     Account reviewer =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setFullName("Reviewer User")
             .setPreferredEmail("reviewer@account.com")
             .build();
     accountCache.put(reviewer);
     Account duplicateCodeOwner =
-        Account.builder(Account.id(4), TimeUtil.nowTs()).setFullName(changeOwner.getName()).build();
+        Account.builder(Account.id(4), TimeUtil.now()).setFullName(changeOwner.getName()).build();
     accountCache.put(duplicateCodeOwner);
     Account duplicateReviewer =
-        Account.builder(Account.id(5), TimeUtil.nowTs()).setFullName(reviewer.getName()).build();
+        Account.builder(Account.id(5), TimeUtil.now()).setFullName(reviewer.getName()).build();
     accountCache.put(duplicateReviewer);
     Change c = newChange();
     ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
@@ -2211,7 +2242,7 @@
         getChangeUpdateBody(c, "Assignee deleted: " + otherUser.getName()),
         getAuthorIdent(changeOwner.getAccount()));
     Account reviewer =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setFullName("Reviewer User")
             .setPreferredEmail("reviewer@account.com")
             .build();
@@ -2250,16 +2281,19 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void singleRunFixesAll() throws Exception {
     Change c = newChange();
-    Timestamp when = TimeUtil.nowTs();
+    Instant when = TimeUtil.now();
     String assigneeIdentToFix = getAccountIdentToFix(otherUser.getAccount());
     PersonIdent authorIdentToFix =
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            when,
+            Date.from(when),
             serverIdent.getTimeZone());
 
     RevCommit invalidUpdateCommit =
@@ -2416,7 +2450,6 @@
   }
 
   private PersonIdent getAuthorIdent(Account account) {
-    Timestamp when = TimeUtil.nowTs();
-    return changeNoteUtil.newAccountIdIdent(account.id(), when, serverIdent);
+    return changeNoteUtil.newAccountIdIdent(account.id(), TimeUtil.now(), serverIdent);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
index 041366c..31b1db0 100644
--- a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -89,7 +89,7 @@
         0,
         otherUser,
         null,
-        TimeUtil.nowTs(),
+        TimeUtil.now(),
         "comment",
         (short) 0,
         ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index d47afb0..fa04cf8 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -17,26 +17,33 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffOperationsTest.FileEntity.FileType;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.io.IOException;
+import java.util.Date;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.lib.TreeFormatter;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
@@ -64,12 +71,15 @@
 
   @Test
   public void diffModifiedFileAgainstParent() throws Exception {
-    ImmutableMap<String, String> oldFiles =
-        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2);
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
     ObjectId oldCommitId = createCommit(repo, null, oldFiles);
 
-    ImmutableMap<String, String> newFiles =
-        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2 + "\nnew line here");
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
     ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
 
     FileDiffOutput diffOutput =
@@ -84,19 +94,18 @@
 
   @Test
   public void diffAgainstAutoMergePersistsAutoMergeInRepo() throws Exception {
-    ObjectId parent1 = createCommit(repo, null, ImmutableMap.of("file_1.txt", "file 1 content"));
-    ObjectId parent2 = createCommit(repo, null, ImmutableMap.of("file_2.txt", "file 2 content"));
+    ObjectId parent1 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity("file_1.txt", "file 1 content")));
+    ObjectId parent2 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity("file_2.txt", "file 2 content")));
 
     ObjectId merge =
         createMergeCommit(
             repo,
-            ImmutableMap.of(
-                "file_1.txt",
-                "file 1 content",
-                "file_2.txt",
-                "file 2 content",
-                "file_3.txt",
-                "file 3 content"),
+            ImmutableList.of(
+                new FileEntity("file_1.txt", "file 1 content"),
+                new FileEntity("file_2.txt", "file 2 content"),
+                new FileEntity("file_3.txt", "file 3 content")),
             parent1,
             parent2);
 
@@ -104,39 +113,158 @@
     assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
 
     Map<String, FileDiffOutput> changedFiles =
-        diffOperations.listModifiedFilesAgainstParent(testProjectName, merge, /* parentNum=*/ 0);
+        diffOperations.listModifiedFilesAgainstParent(
+            testProjectName, merge, /* parentNum=*/ 0, DiffOptions.DEFAULTS);
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST", "file_3.txt");
 
     // Requesting diff against auto-merge had the side effect of updating the auto-merge ref
     assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNotNull();
   }
 
+  @Test
+  public void loadModifiedFiles() throws Exception {
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+    RevWalk rw = new RevWalk(objectReader);
+    StoredConfig repoConfig = repository.getConfig();
+
+    // This call loads modified files directly without going through the diff cache.
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFiles(
+            testProjectName, newCommitId, oldCommitId, DiffOptions.DEFAULTS, rw, repoConfig);
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName2,
+            ModifiedFile.builder()
+                .changeType(ChangeType.MODIFIED)
+                .oldPath(Optional.of(fileName2))
+                .newPath(Optional.of(fileName2))
+                .build());
+  }
+
+  @Test
+  public void loadModifiedFiles_withSymlinkConvertedToRegularFile() throws Exception {
+    // Commit 1: Create a regular fileName1 with fileContent1
+    ImmutableList<FileEntity> oldFiles = ImmutableList.of(new FileEntity(fileName1, fileContent1));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    // Commit 2: Create a symlink with name FileName1 pointing to target file "target"
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(new FileEntity(fileName1, "target", FileType.SYMLINK));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFiles(
+            testProjectName,
+            newCommitId,
+            oldCommitId,
+            DiffOptions.DEFAULTS,
+            new RevWalk(objectReader),
+            repository.getConfig());
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName1,
+            ModifiedFile.builder()
+                .changeType(ChangeType.REWRITE)
+                .oldPath(Optional.empty())
+                .newPath(Optional.of(fileName1))
+                .build());
+  }
+
+  @Test
+  public void loadModifiedFilesAgainstParent() throws Exception {
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+    RevWalk rw = new RevWalk(objectReader);
+    StoredConfig repoConfig = repository.getConfig();
+
+    // This call loads modified files directly without going through the diff cache.
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFilesAgainstParent(
+            testProjectName, newCommitId, /* parentNum=*/ 0, DiffOptions.DEFAULTS, rw, repoConfig);
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName2,
+            ModifiedFile.builder()
+                .changeType(ChangeType.MODIFIED)
+                .oldPath(Optional.of(fileName2))
+                .newPath(Optional.of(fileName2))
+                .build());
+  }
+
+  static class FileEntity {
+    String name;
+    String content;
+    FileType type;
+
+    enum FileType {
+      REGULAR,
+      SYMLINK
+    }
+
+    FileEntity(String name, String content) {
+      this(name, content, FileType.REGULAR);
+    }
+
+    FileEntity(String name, String content, FileType type) {
+      this.name = name;
+      this.content = content;
+      this.type = type;
+    }
+  }
+
   private ObjectId createMergeCommit(
-      Repository repo,
-      ImmutableMap<String, String> fileNameToContent,
-      ObjectId parent1,
-      ObjectId parent2)
+      Repository repo, ImmutableList<FileEntity> fileEntities, ObjectId parent1, ObjectId parent2)
       throws IOException {
-    ObjectId treeId = createTree(repo, fileNameToContent);
+    ObjectId treeId = createTree(repo, fileEntities);
     return createCommitInRepo(repo, treeId, parent1, parent2);
   }
 
   private ObjectId createCommit(
-      Repository repo,
-      @Nullable ObjectId parentCommit,
-      ImmutableMap<String, String> fileNameToContent)
+      Repository repo, @Nullable ObjectId parentCommit, ImmutableList<FileEntity> fileEntities)
       throws IOException {
-    ObjectId treeId = createTree(repo, fileNameToContent);
+    ObjectId treeId = createTree(repo, fileEntities);
     return parentCommit == null
         ? createCommitInRepo(repo, treeId)
         : createCommitInRepo(repo, treeId, parentCommit);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   private static ObjectId createCommitInRepo(Repository repo, ObjectId treeId, ObjectId... parents)
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), Date.from(TimeUtil.now()));
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
@@ -152,17 +280,21 @@
     }
   }
 
-  private static ObjectId createTree(
-      Repository repo, ImmutableMap<String, String> fileNameToContent) throws IOException {
+  private static ObjectId createTree(Repository repo, ImmutableList<FileEntity> fileEntities)
+      throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter();
         ObjectReader reader = repo.newObjectReader();
         RevWalk rw = new RevWalk(reader); ) {
       TreeFormatter formatter = new TreeFormatter();
-      for (Map.Entry<String, String> entry : fileNameToContent.entrySet()) {
-        String fileName = entry.getKey();
-        String fileContent = entry.getValue();
+      for (FileEntity fileEntity : fileEntities) {
+        String fileName = fileEntity.name;
+        String fileContent = fileEntity.content;
         ObjectId fileObjId = createBlob(repo, fileContent);
-        formatter.append(fileName, rw.lookupBlob(fileObjId));
+        if (fileEntity.type.equals(FileType.REGULAR)) {
+          formatter.append(fileName, rw.lookupBlob(fileObjId));
+        } else {
+          formatter.append(fileName, FileMode.SYMLINK, fileObjId);
+        }
       }
       ObjectId treeId = oi.insert(formatter);
       oi.flush();
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
index 93928f0..21ea641 100644
--- a/javatests/com/google/gerrit/server/patch/MagicFileTest.java
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -95,6 +95,9 @@
     assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(1);
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfRootCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -146,6 +149,9 @@
     }
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfNonMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
@@ -202,6 +208,9 @@
     }
   }
 
+  // TODO(issue-15517): Fix the JdkObsolete issue with Date once JGit's PersonIdent class supports
+  // Instants
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void commitMessageFileOfMergeCommitContainsCorrectContent() throws Exception {
     try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
diff --git a/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
index 55c9bc3..ca4872d 100644
--- a/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
+++ b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
@@ -27,11 +27,11 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
-import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 import org.junit.Test;
 
 public class AutoRegisterModulesTest {
@@ -94,7 +94,7 @@
     }
 
     @Override
-    public Enumeration<PluginEntry> entries() {
+    public Stream<PluginEntry> entries() {
       return null;
     }
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 9df59c2..26f7d60 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.RuntimeVersion;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountsSection;
@@ -217,7 +218,7 @@
                 "[submit-requirement \"Code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicableIf =branch(refs/heads/master)\n"
-                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n"
                     + "[submit-requirement \"api-review\"]\n"
                     + "  description =  Additional review required for API modifications\n"
                     + "  applicableIf =commit_filepath_contains(\\\"/api/.*\\\")\n"
@@ -237,7 +238,7 @@
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("branch(refs/heads/master)"))
                 .setSubmittabilityExpression(
-                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                    SubmitRequirementExpression.create("label(Code-Review, +2)"))
                 .setOverrideExpression(Optional.empty())
                 .setAllowOverrideInChildProjects(false)
                 .build(),
@@ -262,19 +263,19 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
-                    + "  submittableIf =  label(code-review, +2)\n")
+                "[submit-requirement \"Code-Review\"]\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
     Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
     assertThat(submitRequirements)
         .containsExactly(
-            "code-review",
+            "Code-Review",
             SubmitRequirement.builder()
-                .setName("code-review")
+                .setName("Code-Review")
                 .setSubmittabilityExpression(
-                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                    SubmitRequirementExpression.create("label(Code-Review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
   }
@@ -320,8 +321,8 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
-                    + "  applicableIf =label(code-review, +2)\n")
+                "[submit-requirement \"Code-Review\"]\n"
+                    + "  applicableIf =label(Code-Review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -330,8 +331,9 @@
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
         .isEqualTo(
-            "project.config: Submit requirement 'code-review' does not define a submittability"
-                + " expression.");
+            "project.config: Setting a submittability expression for submit requirement"
+                + " 'Code-Review' is required: Missing"
+                + " submit-requirement.Code-Review.submittableIf");
   }
 
   @Test
@@ -395,6 +397,31 @@
   }
 
   @Test
+  public void readConfigLabelInvalidBranchPattern() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  value = -1 Negative\n"
+                    + "  value = 0 No Score\n"
+                    + "  value =  1 Positive\n"
+                    + "  branch = ^***\n"
+                    + "  defaultValue = 0\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: Invalid ref pattern \"^***\""
+                + " in label.CustomLabel.branch: Dangling meta character '*' near index 2\n"
+                + "^***\n"
+                + "  ^");
+  }
+
+  @Test
   public void readConfigLabelScores() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -604,7 +631,7 @@
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).hasSize(2);
     assertThat(pluginCfg.getString("key1")).isEqualTo("value1");
-    assertThat(pluginCfg.getStringList(("key2"))).isEqualTo(new String[] {"value2a", "value2b"});
+    assertThat(pluginCfg.getStringList("key2")).isEqualTo(new String[] {"value2a", "value2b"});
   }
 
   @Test
@@ -729,6 +756,7 @@
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(StoredCommentLinkInfo.enabled("bugzilla"));
+    assertThat(Iterables.getOnlyElement(cfg.getCommentLinkSections()).getEnabled()).isNull();
   }
 
   @Test
@@ -740,6 +768,7 @@
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(StoredCommentLinkInfo.disabled("bugzilla"));
+    assertThat(Iterables.getOnlyElement(cfg.getCommentLinkSections()).getEnabled()).isFalse();
   }
 
   @Test
@@ -775,13 +804,19 @@
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections()).isEmpty();
+
+    boolean atLeastJava17 = RuntimeVersion.isAtLeast17();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
             ValidationError.create(
                 "project.config: Invalid pattern \"(bugs{+#?)(d+)\" in commentlink.bugzilla.match: "
-                    + "Illegal repetition near index 4\n"
+                    + "Illegal repetition near index "
+                    + (atLeastJava17 ? "6" : "4")
+                    + "\n"
                     + "(bugs{+#?)(d+)\n"
-                    + "    ^"));
+                    + "    "
+                    + (atLeastJava17 ? "  " : "")
+                    + "^"));
   }
 
   @Test
@@ -952,10 +987,10 @@
         tr.commit()
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
+                "[submit-requirement \"Code-Review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicableIf =branch(refs/heads/master)\n"
-                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n"
                     + "[notify \"name\"]\n"
                     + "  email = example@example.com\n")
             .create();
@@ -1024,6 +1059,24 @@
             });
   }
 
+  @Test
+  public void readCopyValues_emptyValueIsIgnored() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  copyValue = 1\n"
+                    + "  copyValue = 2\n"
+                    + "  copyValue = \n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    assertThat(labels.entrySet().iterator().next().getValue().getCopyValues())
+        .containsExactly((short) 1, (short) 2);
+  }
+
   private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
     Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
     Files.createDirectories(dir);
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 05eb6e0..b05f3c7 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import java.util.Arrays;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,7 +46,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_WITH_BLOCK)
             .build();
 
@@ -55,7 +56,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_NO_BLOCK)
             .build();
 
@@ -65,7 +66,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.ANY_WITH_BLOCK)
             .build();
 
@@ -75,7 +76,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_WITH_BLOCK)
             .setIgnoreSelfApproval(true)
             .build();
@@ -87,14 +88,15 @@
   public void defaultSubmitRule_withLabelsAllPass() {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(
                 createLabel("Code-Review", Label.Status.OK),
                 createLabel("Verified", Label.Status.OK)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -115,14 +117,15 @@
   public void defaultSubmitRule_withLabelsAllNeed() {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(
                 createLabel("Code-Review", Label.Status.NEED),
                 createLabel("Verified", Label.Status.NEED)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -140,15 +143,42 @@
   }
 
   @Test
+  public void defaultSubmitRule_withOneLabelForced() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(createLabel("Code-Review", Label.Status.NEED)));
+
+    // Submit records that are forced are written with their initial status in NoteDb, e.g. NEED.
+    // If we do a force submit, the gerrit server appends an extra marker record with status=FORCED
+    // to indicate that all other records were forced, that's why we explicitly pass isForced=true
+    // to the "submit requirements adapter". The resulting submit requirement result has a
+    // status=FORCED.
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ true);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.FORCED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
   public void defaultSubmitRule_withLabelStatusNeed_labelHasIgnoreSelfApproval() throws Exception {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.NOT_READY,
             Arrays.asList(createLabel("ISA-Label", Label.Status.NEED)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -163,12 +193,13 @@
   public void defaultSubmitRule_withLabelStatusOk_labelHasIgnoreSelfApproval() throws Exception {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(createLabel("ISA-Label", Label.Status.OK)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -180,12 +211,52 @@
   }
 
   @Test
+  public void defaultSubmitRule_withNonExistingLabel() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(createLabel("Non-Existing", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
+
+    assertThat(requirements).isEmpty();
+  }
+
+  @Test
+  public void defaultSubmitRule_withExistingAndNonExistingLabels() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(
+                createLabel("Non-Existing", Label.Status.OK),
+                createLabel("Code-Review", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
+
+    // The "Non-Existing" label was skipped since it does not exist in the project config.
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
   public void customSubmitRule_noLabels_withStatusOk() {
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -202,7 +273,8 @@
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, /* labels= */ null);
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -219,7 +291,8 @@
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.NOT_READY, Arrays.asList());
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -241,7 +314,8 @@
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -269,7 +343,8 @@
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -296,7 +371,7 @@
     assertThat(r.submitRequirement().submittabilityExpression().expressionString())
         .isEqualTo(submitExpression);
     assertThat(r.status()).isEqualTo(status);
-    assertThat(r.submittabilityExpressionResult().status()).isEqualTo(expressionStatus);
+    assertThat(r.submittabilityExpressionResult().get().status()).isEqualTo(expressionStatus);
   }
 
   private SubmitRecord createSubmitRecord(
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index 4ae2039..c255f5d 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -53,11 +53,9 @@
     visibility = ["//visibility:public"],
     deps = [
         ":abstract_query_tests",
-        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index c3b220b..28d9ac7 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -26,6 +26,7 @@
 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.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -40,6 +41,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -48,6 +52,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -112,6 +117,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -138,11 +144,11 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
@@ -278,7 +284,10 @@
   @After
   public void resetTime() {
     TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
+    if (systemTimeZone != null) {
+      System.setProperty("user.timezone", systemTimeZone);
+      systemTimeZone = null;
+    }
   }
 
   @Test
@@ -341,8 +350,10 @@
     assertQuery("is:new", change1);
     assertQuery("status:merged", change2);
     assertQuery("is:merged", change2);
-    assertQuery("status:draft");
-    assertQuery("is:draft");
+    Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("is:draft"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: draft");
+    thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:draft"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: draft");
   }
 
   @Test
@@ -415,8 +426,10 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
-    assertQuery("status:nx");
-    assertQuery("status:newx");
+    Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:newx"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: newx");
+    thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:nx"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: nx");
   }
 
   @Test
@@ -921,6 +934,7 @@
 
   @Test
   public void byTopic() throws Exception {
+
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
     Change change1 = insert(repo, ins1);
@@ -951,6 +965,11 @@
     assertQuery("intopic:gerrit", change6, change5);
     assertQuery("topic:\"\"", change_no_topic);
     assertQuery("intopic:\"\"", change_no_topic);
+
+    assume().that(getSchema().hasField(ChangeField.PREFIX_TOPIC)).isTrue();
+    assertQuery("prefixtopic:feature", change4, change2, change1);
+    assertQuery("prefixtopic:Cher", change3);
+    assertQuery("prefixtopic:feature22");
   }
 
   @Test
@@ -977,10 +996,34 @@
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("A great \"fix\" to my bug").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
 
     assertQuery("message:foo");
     assertQuery("message:one", change1);
     assertQuery("message:two", change2);
+    assertQuery("message:\"great \\\"fix\\\" to\"", change3);
+  }
+
+  @Test
+  public void byMessageRegEx() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nHELLO WORLD").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    RevCommit commit4 =
+        repo.parseBody(repo.commit().message("Title\n\nfoobar hello WORLD").create());
+    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+
+    assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
+    assertQuery("message:\"^aaaa(c)*c.*\"", change2);
+    assertQuery("message:\"^.*HELLO WORLD.*\"", change3);
+    assertQuery(
+        "message:\"^.*(H|h)(E|e)(L|l)(L|l)(O|o) (W|w)(O|o)(R|r)(L|l)(D|d).*\"", change4, change3);
   }
 
   @Test
@@ -1040,6 +1083,7 @@
     ChangeInserter ins3 = newChange(repo);
     ChangeInserter ins4 = newChange(repo);
     ChangeInserter ins5 = newChange(repo);
+    ChangeInserter ins6 = newChange(repo);
 
     Change reviewMinus2Change = insert(repo, ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
@@ -1052,7 +1096,13 @@
     Change reviewPlus1Change = insert(repo, ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    Change reviewPlus2Change = insert(repo, ins5);
+    Change reviewTwoPlus1Change = insert(repo, ins5);
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(createAccount("user1")));
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(userId));
+
+    Change reviewPlus2Change = insert(repo, ins6);
     gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1063,8 +1113,10 @@
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
-    Map<Integer, Change> changes = new LinkedHashMap<>(5);
+    Multimap<Integer, Change> changes =
+        Multimaps.newListMultimap(Maps.newLinkedHashMap(), () -> Lists.newArrayList());
     changes.put(2, reviewPlus2Change);
+    changes.put(1, reviewTwoPlus1Change);
     changes.put(1, reviewPlus1Change);
     changes.put(0, noLabelChange);
     changes.put(-1, reviewMinus1Change);
@@ -1076,9 +1128,9 @@
     assertQuery("label:Code-Review=-1", reviewMinus1Change);
     assertQuery("label:Code-Review-1", reviewMinus1Change);
     assertQuery("label:Code-Review=0", noLabelChange);
-    assertQuery("label:Code-Review=+1", reviewPlus1Change);
-    assertQuery("label:Code-Review=1", reviewPlus1Change);
-    assertQuery("label:Code-Review+1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review+1", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2", reviewPlus2Change);
     assertQuery("label:Code-Review=2", reviewPlus2Change);
     assertQuery("label:Code-Review+2", reviewPlus2Change);
@@ -1086,6 +1138,7 @@
     assertQuery(
         "label:Code-Review=ANY",
         reviewPlus2Change,
+        reviewTwoPlus1Change,
         reviewPlus1Change,
         reviewMinus1Change,
         reviewMinus2Change);
@@ -1114,14 +1167,70 @@
     assertQuery("label:Code-Review<-2");
 
     assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=owner", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
     assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
+
+    // count=0 is not allowed
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=+2,count=0"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Argument count=0 is not allowed.");
+    assertQuery("label:Code-Review=1,count=1", reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>1", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=3");
+    thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=1,count=7"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("count=7 is not allowed. Maximum allowed value for count is 5.");
+
+    // Less than operator does not match with changes having count=0 for a specific vote value (i.e.
+    // no votes for that specific value). We do that deliberately since the computation of count=0
+    // for label values is expensive when the change is re-indexed. This is because the operation
+    // requires generating all formats for all {label-type, vote}=0 values that are non-voted for
+    // the change and storing them with the 'count=0' format.
+    assertQuery("label:Code-Review=1,count<5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=1", // reviewTwoPlus1Change is not matched since its count=2
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=1 label:Code-Review=1,count>=1", reviewPlus1Change);
+
+    assertQuery("label:Code-Review=MAX,count=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MAX,count=2");
+    assertQuery("label:Code-Review=MIN,count=1", reviewMinus2Change);
+    assertQuery("label:Code-Review=MIN,count>1");
+    assertQuery("label:Code-Review=MAX,count<2", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1");
+    assertQuery("label:Code-Review=MAX,count<2 label:Code-Review=MAX,count>=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1 label:Code-Review=MIN,count>=1");
+    assertQuery("label:Code-Review>=+1,count=2", reviewTwoPlus1Change);
+
+    // "count" and "user" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class,
+        () -> assertQuery("label:Code-Review=+1,user=non_uploader,count=2"));
+
+    // "count" and "group" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class, () -> assertQuery("label:Code-Review=+1,group=gerrit,count=2"));
   }
 
   @Test
@@ -1226,16 +1335,15 @@
     assertQuery("label:Code-Review=+1,non_uploader", reviewPlus1Change);
   }
 
-  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
-    int size = 0;
-    Change[] range = new Change[end - start + 1];
-    for (int i : changes.keySet()) {
+  private Change[] codeReviewInRange(Multimap<Integer, Change> changes, int start, int end) {
+    List<Change> range = new ArrayList<>();
+    for (Map.Entry<Integer, Change> entry : changes.entries()) {
+      int i = entry.getKey();
       if (i >= start && i <= end) {
-        range[size] = changes.get(i);
-        size++;
+        range.add(entry.getValue());
       }
     }
-    return range;
+    return range.toArray(new Change[0]);
   }
 
   private String createGroup(String name, String owner) throws Exception {
@@ -1632,6 +1740,27 @@
   }
 
   @Test
+  public void byFooterName() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    // create a changes with lines that look like footers, but which are not
+    RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
+    insert(repo, newChangeForCommit(repo, commit6));
+
+    // matching by 'key=value' works
+    assertQuery("hasfooter:foo", change1);
+
+    // case matters
+    assertQuery("hasfooter:BaR", change2);
+    assertQuery("hasfooter:Bar");
+  }
+
+  @Test
   public void byDirectory() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
@@ -1746,8 +1875,9 @@
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
 
     // Stop time so age queries use the same endpoint.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -1786,8 +1916,9 @@
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -1837,8 +1968,9 @@
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -1905,12 +2037,13 @@
     TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
     submit(change2);
     TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
-    // Put another approval on the change, just to update it.
+    // Put another approval on the change, just to update it. This does not record an update in
+    // NoteDb since this is a no/op.
     approve(change1);
     approve(change3);
 
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
@@ -1929,7 +2062,7 @@
     assertQuery("mergedbefore:2009-10-03", change3);
     // Changes are sorted by lastUpdatedOn first, then by mergedOn.
     // Even though Change2 was merged after Change3, Change3 is returned first.
-    assertQuery("mergedbefore:2009-10-04", change3, change2);
+    assertQuery("mergedbefore:2009-10-04", change2, change3);
 
     // Same test as above, but using filter code path.
     assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-01"));
@@ -1942,7 +2075,7 @@
         makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\""), change3);
     assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00\""), change3);
     assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-03"), change3);
-    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change2, change3);
   }
 
   @Test
@@ -1967,13 +2100,14 @@
     submit(change2);
 
     TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
-    // Put another approval on the change, just to update it.
+    // Put another approval on the change, just to update it. This does not record an update
+    // in NoteDb since this is a no/op.
     approve(change1);
     approve(change3);
 
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
@@ -1981,36 +2115,36 @@
     // 1. Change1 was not submitted and should be never returned.
     // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
     // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
-    assertQuery("mergedafter:2009-10-01", change3, change2);
+    assertQuery("mergedafter:2009-10-01", change2, change3);
     // Changes are sorted by lastUpdatedOn first, then by mergedOn.
-    // Even though Change2 was merged after Change3, Change3 is returned first.
-    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change3, change2);
-    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change3, change2);
+    // Change 2 (which was updated last) is returned before change 3.
+    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change2, change3);
+    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change2, change3);
     assertQuery("mergedafter:\"2009-10-01 23:02:00 -0400\"", change2);
     assertQuery("mergedafter:\"2009-10-02 03:02:00 -0000\"", change2);
     // Changes included on the date submitted.
-    assertQuery("mergedafter:2009-10-02", change3, change2);
+    assertQuery("mergedafter:2009-10-02", change2, change3);
     assertQuery("mergedafter:2009-10-03", change2);
 
     // Same test as above, but using filter code path.
 
-    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change2, change3);
     // Changes are sorted by lastUpdatedOn first, then by mergedOn.
     // Even though Change2 was merged after Change3, Change3 is returned first.
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 22:59:00 -0400\""),
-        change3,
-        change2);
+        change2,
+        change3);
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 02:59:00 -0000\""),
-        change3,
-        change2);
+        change2,
+        change3);
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 23:02:00 -0400\""), change2);
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 03:02:00 -0000\""), change2);
     // Changes included on the date submitted.
-    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-02"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-02"), change2, change3);
     assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-03"), change2);
   }
 
@@ -2033,14 +2167,15 @@
     submit(change2);
     submit(change3);
     TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
-    // Approve post submit just to update lastUpdatedOn
+    // Approve post submit just to update lastUpdatedOn. This does not record an update in NoteDb
+    // since this is a No/op.
     approve(change3);
     approve(change2);
     submit(change1);
 
     // All Changes were last updated at the same time.
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 2 * thirtyHoursInMs);
-    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 2 * thirtyHoursInMs);
 
     // Changes are sorted by lastUpdatedOn first, then by mergedOn, then by Id in reverse order.
@@ -2148,6 +2283,15 @@
   }
 
   @Test
+  public void byHashtagPrefix() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_HASHTAG)).isTrue();
+    List<Change> changes = setUpHashtagChanges();
+    assertQuery("prefixhashtag:a", changes.get(1), changes.get(0));
+    assertQuery("prefixhashtag:aa", changes.get(0));
+    assertQuery("prefixhashtag:bar", changes.get(1));
+  }
+
+  @Test
   public void byHashtagRegex() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -2366,18 +2510,17 @@
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
       Change change = insert(repo, newChange(repo));
-      assertQuery("rule:gerrit~FakeSubmitRule");
+      // The fake submit rule exports its ruleName as "FakeSubmitRule"
+      assertQuery("rule:FakeSubmitRule");
 
       // FakeSubmitRule returns true if change has one or more hashtags.
       HashtagsInput hashtag = new HashtagsInput();
       hashtag.add = ImmutableSet.of("Tag1");
       gApi.changes().id(change.getId().get()).setHashtags(hashtag);
-      assertQuery("rule:gerrit~FakeSubmitRule", change);
-      assertQuery("rule:gerrit~FakeSubmitRule=OK", change);
-      assertQuery("rule:gerrit~FakeSubmitRule=NOT_READY");
 
-      // The 'gerrit~' prefix can be omitted for core submit rules
       assertQuery("rule:FakeSubmitRule", change);
+      assertQuery("rule:FakeSubmitRule=OK", change);
+      assertQuery("rule:FakeSubmitRule=NOT_READY");
     }
   }
 
@@ -2399,7 +2542,21 @@
   }
 
   @Test
-  public void byHasDraft() throws Exception {
+  public void byHasDraft_draftsComputedFromIndex() throws Exception {
+    byHasDraft();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byHasDraft_draftsComputedFromAllUsersRepository() throws Exception {
+    byHasDraft();
+  }
+
+  private void byHasDraft() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2427,7 +2584,11 @@
     assertQuery("has:draft");
   }
 
-  @Test
+  /**
+   * This test does not have a test about drafts computed from All-Users Repository because zombie
+   * drafts can't be filtered when computing from All-Users repository. TODO(paiking): During
+   * rollout, we should find a way to fix zombie drafts.
+   */
   public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
@@ -2465,8 +2626,62 @@
     assertQuery("has:draft");
   }
 
+  public void byHasDraftWithManyDrafts_draftsComputedFromIndex() throws Exception {
+    byHasDraftWithManyDrafts();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byHasDraftWithManyDrafts_draftsComputedFromAllUsersRepository() throws Exception {
+    byHasDraftWithManyDrafts();
+  }
+
+  private void byHasDraftWithManyDrafts() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change[] changesWithDrafts = new Change[30];
+
+    // unrelated change not shown in the result.
+    insert(repo, newChange(repo));
+
+    for (int i = 0; i < changesWithDrafts.length; i++) {
+      // put the changes in reverse order since this is the order we receive them from the index.
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      DraftInput in = new DraftInput();
+      in.line = 1;
+      in.message = "nit: trailing whitespace";
+      in.path = Patch.COMMIT_MSG;
+      gApi.changes()
+          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().get())
+          .current()
+          .createDraft(in);
+    }
+    assertQuery("has:draft", changesWithDrafts);
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:draft");
+  }
+
   @Test
-  public void byStarredBy() throws Exception {
+  public void byStarredBy_starsComputedFromIndex() throws Exception {
+    byStarredBy();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  @Test
+  public void byStarredBy_starsComputedFromAllUsersRepository() throws Exception {
+    byStarredBy();
+  }
+
+  private void byStarredBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2487,7 +2702,21 @@
   }
 
   @Test
-  public void byStar() throws Exception {
+  public void byStar_starsComputedFromIndex() throws Exception {
+    byStar();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  @Test
+  public void byStar_starsComputedFromAllUsersRepository() throws Exception {
+    byStar();
+  }
+
+  private void byStar() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
     Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
@@ -2513,7 +2742,21 @@
   }
 
   @Test
-  public void byIgnore() throws Exception {
+  public void byIgnore_starsComputedFromIndex() throws Exception {
+    byIgnore();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byIgnore_starsComputedFromAllUsersRepository() throws Exception {
+    byIgnore();
+  }
+
+  private void byIgnore() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2533,6 +2776,42 @@
     assertQuery("-star:ignore", change2, change1);
   }
 
+  public void byStarWithManyStars_starsComputedFromIndex() throws Exception {
+    byStarWithManyStars();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byStarWithManyStars_starsComputedFromAllUsersRepository() throws Exception {
+    byStarWithManyStars();
+  }
+
+  private void byStarWithManyStars() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change[] changesWithDrafts = new Change[30];
+    for (int i = 0; i < changesWithDrafts.length; i++) {
+      // put the changes in reverse order since this is the order we receive them from the index.
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+
+      // star the change
+      gApi.accounts()
+          .self()
+          .starChange(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString());
+
+      // ignore the change
+      gApi.changes()
+          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString())
+          .ignore(true);
+    }
+
+    // all changes are both starred and ignored.
+    assertQuery("is:ignored", changesWithDrafts);
+    assertQuery("is:starred", changesWithDrafts);
+  }
+
   @Test
   public void byFrom() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
@@ -2865,8 +3144,6 @@
 
     assertQuery("is:submittable", change1);
     assertQuery("-is:submittable", change2);
-    assertQuery("submittable:ok", change1);
-    assertQuery("submittable:not_ready", change2);
 
     assertQuery("label:CodE-RevieW=ok", change1);
     assertQuery("label:CodE-RevieW=ok,user=user", change1);
@@ -2880,8 +3157,8 @@
     assertQuery("label:CodE-RevieW=need,user");
 
     gApi.changes().id(change1.getId().get()).current().submit();
-    assertQuery("submittable:ok");
-    assertQuery("submittable:closed", change1);
+    assertQuery("is:submittable");
+    assertQuery("-is:submittable", change1, change2);
   }
 
   @Test
@@ -3795,6 +4072,33 @@
   }
 
   @Test
+  public void isPureRevert() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    // Create two commits and revert second commit (initial commit can't be reverted)
+    Change initial = insert(repo, newChange(repo));
+    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(initial.getChangeId()).current().submit();
+
+    ChangeInfo changeToRevert =
+        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
+    gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToRevert.id).current().submit();
+
+    ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
+    Change.Id changeThatRevertsId = Change.id(changeThatReverts._number);
+    assertQueryByIds("is:pure-revert", changeThatRevertsId);
+
+    // Update the change that reverts such that it's not a pure revert
+    gApi.changes()
+        .id(changeThatReverts.id)
+        .edit()
+        .modifyFile("some-file.txt", RawInputUtil.create("newcontent".getBytes(UTF_8)));
+    gApi.changes().id(changeThatReverts.id).edit().publish();
+    assertQueryByIds("is:pure-revert");
+  }
+
+  @Test
   public void selfFailsForAnonymousUser() throws Exception {
     for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
       assertQuery(query);
@@ -3944,19 +4248,16 @@
   }
 
   protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
-    return insert(repo, ins, null, TimeUtil.nowTs());
+    return insert(repo, ins, null, TimeUtil.now());
   }
 
   protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
       throws Exception {
-    return insert(repo, ins, owner, TimeUtil.nowTs());
+    return insert(repo, ins, owner, TimeUtil.now());
   }
 
   protected Change insert(
-      TestRepository<Repo> repo,
-      ChangeInserter ins,
-      @Nullable Account.Id owner,
-      Timestamp createdOn)
+      TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
       throws Exception {
     Project.NameKey project =
         Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
@@ -3982,7 +4283,7 @@
             .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
             .setFireRevisionCreated(false)
             .setValidate(false);
-    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.nowTs());
+    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
         ObjectInserter oi = repo.getRepository().newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
@@ -4121,7 +4422,7 @@
   }
 
   protected static long lastUpdatedMs(Change c) {
-    return c.getLastUpdatedOn().getTime();
+    return c.getLastUpdatedOn().toEpochMilli();
   }
 
   // Get the last  updated time from ChangeApi
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 802bf54..32a646e 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -27,7 +27,6 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/group/testing",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index e42230f..e48d4af 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -46,7 +46,7 @@
         .id(PatchSet.id(changeId, num))
         .commitId(ObjectId.zeroId())
         .uploader(Account.id(1234))
-        .createdOn(TimeUtil.nowTs())
+        .createdOn(TimeUtil.now())
         .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 4b74325..0cc132d 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -54,6 +54,5 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index a65306c..f476ae6 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -51,10 +51,8 @@
     deps = [
         ":abstract_query_tests",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index 46f9c5a..4c8750a 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -38,9 +38,10 @@
 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.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -78,7 +79,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(DiffNotAvailableException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -101,7 +105,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -144,7 +151,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -173,7 +183,10 @@
         .thenReturn(Optional.of(dummyObjectId));
     // Throw an exception on the first diff request but return an actual value on the second.
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class)
         .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
@@ -200,7 +213,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -215,7 +231,7 @@
         changeId,
         Account.id(123),
         BranchNameKey.create(project, "myBranch"),
-        new Timestamp(12345));
+        Instant.ofEpochMilli(12345));
   }
 
   private PatchSet createPatchset(PatchSet.Id id) {
@@ -223,7 +239,7 @@
         .id(id)
         .commitId(dummyObjectId)
         .uploader(Account.id(123))
-        .createdOn(new Timestamp(12345))
+        .createdOn(Instant.ofEpochMilli(12345))
         .build();
   }
 
@@ -246,7 +262,7 @@
     return new HumanComment(
         new Comment.Key(getUniqueUuid(), filePath, patchsetId.get()),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 44c3cef..2685a8b 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
@@ -124,9 +127,11 @@
   /** Create a new change message with an id, message, timestamp and tag */
   private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
-    ChangeMessage cm =
-        ChangeMessage.create(
-            key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null, message, null, tag);
+    Instant timestamp =
+        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.systemDefault())
+            .parse("2000-01-01 00:00:" + ts, Instant::from);
+    ChangeMessage cm = ChangeMessage.create(key, null, timestamp, null, message, null, tag);
     return cm;
   }
 
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index e5dd817..509447a 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -27,7 +27,6 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
@@ -84,7 +83,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(PS_ID, accountId, labelId))
         .value(value)
-        .granted(Date.from(Instant.now()))
+        .granted(Instant.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 646f0cd..767ac28 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -34,8 +34,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestUpdateUI;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.junit.TestRepository;
@@ -84,7 +82,7 @@
     protected final NoteDbSchemaUpdater updater;
     protected final GitRepositoryManager repoManager;
     protected final NoteDbSchemaVersion.Arguments args;
-    private final List<String> messages;
+    private final ImmutableList.Builder<String> messages;
 
     TestUpdate(Optional<Integer> initialVersion) {
       cfg = new Config();
@@ -106,7 +104,7 @@
               versionManager,
               args,
               ImmutableSortedMap.of(10, TestSchema_10.class, 11, TestSchema_11.class));
-      messages = new ArrayList<>();
+      messages = ImmutableList.builder();
     }
 
     private class TestSchemaCreator implements SchemaCreator {
@@ -173,7 +171,7 @@
     }
 
     ImmutableList<String> getMessages() {
-      return ImmutableList.copyOf(messages);
+      return messages.build();
     }
 
     Optional<Integer> readVersion() throws Exception {
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
index fb995fd..8702755 100644
--- a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
@@ -38,8 +38,11 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.runner.RunWith;
 import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class SubscriptionGraphTest {
   private static final String TEST_PATH = "test/path";
   private static final Project.NameKey SUPER_PROJECT = Project.nameKey("Superproject");
@@ -51,9 +54,9 @@
   private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
   private MergeOpRepoManager mergeOpRepoManager;
 
-  @Mock GitModules.Factory mockGitModulesFactory = mock(GitModules.Factory.class);
-  @Mock ProjectCache mockProjectCache = mock(ProjectCache.class);
-  @Mock ProjectState mockProjectState = mock(ProjectState.class);
+  @Mock GitModules.Factory mockGitModulesFactory;
+  @Mock ProjectCache mockProjectCache;
+  @Mock ProjectState mockProjectState;
 
   @Before
   public void setUp() throws Exception {
@@ -61,7 +64,6 @@
     mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
 
     GitModules emptyMockGitModules = mock(GitModules.class);
-    when(emptyMockGitModules.subscribedTo(any())).thenReturn(ImmutableSet.of());
     when(mockGitModulesFactory.create(any(), any())).thenReturn(emptyMockGitModules);
 
     TestRepository<Repository> superProject = createRepo(SUPER_PROJECT);
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 10599c6..1f22564 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -95,7 +95,7 @@
     RevCommit masterCommit = repo.branch("master").commit().create();
     RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
 
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addRepoOnlyOp(
           new RepoOnlyOp() {
             @Override
@@ -114,7 +114,7 @@
   public void cannotExceedMaxUpdates() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Excessive update"));
       ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
       assertThat(thrown)
@@ -130,7 +130,7 @@
     Change.Id id = createChangeWithPatchSets(2);
 
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1)));
       bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2)));
       ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
@@ -146,7 +146,7 @@
   public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -165,7 +165,7 @@
   public void exceedingMaxUpdatesAllowedWithNoOpAfterPopulatingUpdate() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -185,7 +185,7 @@
   public void exceedingMaxUpdatesAllowedWithSubmit() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new SubmitOp());
       bu.execute();
     }
@@ -197,7 +197,7 @@
   public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception {
     Change.Id id = createChangeWithPatchSets(2);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1)));
       bu.addOp(id, new SubmitOp());
       bu.execute();
@@ -212,7 +212,7 @@
   public void exceedingMaxUpdatesAllowedWithAbandon() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -235,7 +235,7 @@
     Change.Id changeId = createChangeWithPatchSets(MAX_PATCH_SETS);
     ObjectId oldMetaId = getMetaId(changeId);
     ChangeNotes notes = changeNotesFactory.create(project, changeId);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId()).message("kaboom").create();
       bu.addOp(
@@ -257,7 +257,7 @@
     Change.Id changeId = createChangeWithUpdates(1);
     ChangeNotes notes = changeNotesFactory.create(project, changeId);
 
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId())
               .add("bar.txt", "bar")
@@ -285,7 +285,7 @@
     int cacheSizeBefore = diffSummaryCache.asMap().size();
 
     // We don't want to depend on the test helper used above so we perform an explicit commit here.
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId())
               .add("bar.txt", "bar")
@@ -309,7 +309,7 @@
     checkArgument(totalUpdates > 0);
     checkArgument(totalUpdates <= MAX_UPDATES);
     Change.Id id = Change.id(sequences.nextChangeId());
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.insertChange(
           changeInserterFactory.create(
               id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
@@ -317,7 +317,7 @@
     }
     assertThat(getUpdateCount(id)).isEqualTo(1);
     for (int i = 2; i <= totalUpdates; i++) {
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
         bu.addOp(id, new AddMessageOp("Update " + i));
         bu.execute();
       }
@@ -331,7 +331,7 @@
     Change.Id id = createChangeWithUpdates(MAX_UPDATES - 2);
     ChangeNotes notes = changeNotesFactory.create(project, id);
     for (int i = 2; i <= patchSets; ++i) {
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
         ObjectId commitId =
             repo.amend(notes.getCurrentPatchSet().commitId()).message("PS" + i).create();
         bu.addOp(
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 0bb4de4..ebdf2d9 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -269,8 +269,8 @@
         .filter(s -> !s.isEmpty())
         .map(
             (String cookieValue) -> {
-              String[] kv = cookieValue.split("=");
-              return new Cookie(kv[0], kv[1]);
+              List<String> kv = Splitter.on("=").splitToList(cookieValue);
+              return new Cookie(kv.get(0), kv.get(1));
             })
         .collect(toList())
         .toArray(new Cookie[0]);
diff --git a/lib/BUILD b/lib/BUILD
index 660b041..7aa9a45 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -47,13 +47,6 @@
 )
 
 java_library(
-    name = "jgit-ssh-jsch",
-    data = ["//lib:LICENSE-jgit"],
-    visibility = ["//visibility:public"],
-    exports = ["@jgit//org.eclipse.jgit.ssh.jsch:ssh-jsch"],
-)
-
-java_library(
     name = "jgit-ssh-apache",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
@@ -99,7 +92,10 @@
     name = "protobuf",
     data = ["//lib:LICENSE-protobuf"],
     visibility = ["//visibility:public"],
-    exports = ["@com_google_protobuf//:protobuf_java"],
+    exports = [
+        "@com_google_protobuf//:protobuf_java",
+        "@com_google_protobuf//:protobuf_javalite",
+    ],
 )
 
 java_library(
@@ -128,6 +124,15 @@
 )
 
 java_library(
+    name = "guava-testlib",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@guava-testlib//jar",
+    ],
+)
+
+java_library(
     name = "caffeine",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = [
@@ -153,13 +158,6 @@
 )
 
 java_library(
-    name = "jsch",
-    data = ["//lib:LICENSE-jsch"],
-    visibility = ["//visibility:public"],
-    exports = ["@jsch//jar"],
-)
-
-java_library(
     name = "juniversalchardet",
     data = ["//lib:LICENSE-MPL1.1"],
     visibility = ["//visibility:public"],
@@ -540,18 +538,6 @@
     exports = ["@icu4j//jar"],
 )
 
-java_library(
-    name = "javax-annotation",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = [
-        "//java/com/google/gerrit/acceptance:__pkg__",
-        "//java/com/google/gerrit/extensions:__pkg__",
-        "//java/com/google/gerrit/server:__pkg__",
-        "//plugins:__subpackages__",
-    ],
-    exports = ["@javax-annotation//jar"],
-)
-
 sh_test(
     name = "nongoogle_test",
     srcs = ["nongoogle_test.sh"],
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index 38b1b6d..091ea07 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -15,12 +15,6 @@
 )
 
 java_library(
-    name = "lang",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@commons-lang//jar"],
-)
-
-java_library(
     name = "lang3",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@commons-lang3//jar"],
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index b10bc55..4105d85 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -1,3 +1,28 @@
-exports_files([
-    "highlight.min.js",
-])
+# build highlight.min.js from node modules
+
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+
+package(
+    default_visibility = ["//visibility:public"],
+    licenses = ["notice"],
+)
+
+rollup_bundle(
+    name = "highlight.min",
+    srcs = [
+        "@ui_npm//highlight.js",
+        "@ui_npm//highlightjs-closure-templates",
+        "@ui_npm//highlightjs-structured-text",
+    ],
+    config_file = "rollup.config.js",
+    entry_point = "index.js",
+    format = "iife",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
+    sourcemap = "hidden",
+    deps = [
+        "@tools_npm//rollup",
+        "@tools_npm//rollup-plugin-commonjs",
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
+)
diff --git a/lib/highlightjs/building.md b/lib/highlightjs/building.md
deleted file mode 100644
index 18c5746..0000000
--- a/lib/highlightjs/building.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Building Highlight.js for Gerrit
-
-Highlight JS needs to be built with specific language support. Here are the
-steps to build the minified file that appears here.
-
-NOTE: If you are adding support for a language to Highlight.js make sure to add
-it to the list of languages in the build command below.
-
-## Prerequisites
-
-You will need:
-
-* nodejs
-* closure-compiler
-* git
-
-## Steps to Create the Pack File
-
-The packed version of Highlight.js is an un-minified JS file with all of the
-languages included. Build it with the following:
-
-    $>  # start in some temp directory
-    $>  git clone https://github.com/highlightjs/highlight.js
-    $>  cd highlight.js
-    $>  git clone https://github.com/highlightjs/highlightjs-closure-templates
-    $>  ln -s ../../highlightjs-closure-templates/soy.js src/languages/soy.js
-    $>  mkdir test/detect/soy && ln -s ../../../highlightjs-closure-templates/test/detect/soy/default.txt test/detect/soy/default.txt
-    $>  npm install
-    $>  node tools/build.js
-
-The resulting minified JS file will appear in the "build" directory of the Highlight.js
-repo under the name "highlight.min.js".
-
-## Finish
-
-Copy the resulting build/highlight.min.js file to lib/highlightjs
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
deleted file mode 100644
index ad4d1fe..0000000
--- a/lib/highlightjs/highlight.min.js
+++ /dev/null
@@ -1,3712 +0,0 @@
-/*
-  Highlight.js 10.7.2 (00233d63)
-  License: BSD-3-Clause
-  Copyright (c) 2006-2021, Ivan Sagalaev
-*/
-var hljs=function(){"use strict";function e(t){
-return t instanceof Map?t.clear=t.delete=t.set=()=>{
-throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{
-throw Error("set is read-only")
-}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{var i=t[n]
-;"object"!=typeof i||Object.isFrozen(i)||e(i)})),t}var t=e,n=e;t.default=n
-;class i{constructor(e){
-void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}
-ignoreMatch(){this.isMatchIgnored=!0}}function s(e){
-return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")
-}function a(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]
-;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const r=e=>!!e.kind
-;class l{constructor(e,t){
-this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){
-this.buffer+=s(e)}openNode(e){if(!r(e))return;let t=e.kind
-;e.sublanguage||(t=`${this.classPrefix}${t}`),this.span(t)}closeNode(e){
-r(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){
-this.buffer+=`<span class="${e}">`}}class o{constructor(){this.rootNode={
-children:[]},this.stack=[this.rootNode]}get top(){
-return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){
-this.top.children.push(e)}openNode(e){const t={kind:e,children:[]}
-;this.add(t),this.stack.push(t)}closeNode(){
-if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){
-for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}
-walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){
-return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t),
-t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){
-"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{
-o._collapse(e)})))}}class c extends o{constructor(e){super(),this.options=e}
-addKeyword(e,t){""!==e&&(this.openNode(t),this.addText(e),this.closeNode())}
-addText(e){""!==e&&this.add(e)}addSublanguage(e,t){const n=e.root
-;n.kind=t,n.sublanguage=!0,this.add(n)}toHTML(){
-return new l(this,this.options).value()}finalize(){return!0}}function g(e){
-return e?"string"==typeof e?e:e.source:null}
-const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,h="[a-zA-Z]\\w*",d="[a-zA-Z_]\\w*",f="\\b\\d+(\\.\\d+)?",p="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",m="\\b(0b[01]+)",b={
-begin:"\\\\[\\s\\S]",relevance:0},E={className:"string",begin:"'",end:"'",
-illegal:"\\n",contains:[b]},x={className:"string",begin:'"',end:'"',
-illegal:"\\n",contains:[b]},v={
-begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/
-},w=(e,t,n={})=>{const i=a({className:"comment",begin:e,end:t,contains:[]},n)
-;return i.contains.push(v),i.contains.push({className:"doctag",
-begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),i
-},y=w("//","$"),N=w("/\\*","\\*/"),R=w("#","$");var _=Object.freeze({
-__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:h,UNDERSCORE_IDENT_RE:d,
-NUMBER_RE:f,C_NUMBER_RE:p,BINARY_NUMBER_RE:m,
-RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",
-SHEBANG:(e={})=>{const t=/^#![ ]*\//
-;return e.binary&&(e.begin=((...e)=>e.map((e=>g(e))).join(""))(t,/.*\b/,e.binary,/\b.*/)),
-a({className:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{
-0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:b,APOS_STRING_MODE:E,
-QUOTE_STRING_MODE:x,PHRASAL_WORDS_MODE:v,COMMENT:w,C_LINE_COMMENT_MODE:y,
-C_BLOCK_COMMENT_MODE:N,HASH_COMMENT_MODE:R,NUMBER_MODE:{className:"number",
-begin:f,relevance:0},C_NUMBER_MODE:{className:"number",begin:p,relevance:0},
-BINARY_NUMBER_MODE:{className:"number",begin:m,relevance:0},CSS_NUMBER_MODE:{
-className:"number",
-begin:f+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",
-relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",
-begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b,{begin:/\[/,end:/\]/,
-relevance:0,contains:[b]}]}]},TITLE_MODE:{className:"title",begin:h,relevance:0
-},UNDERSCORE_TITLE_MODE:{className:"title",begin:d,relevance:0},METHOD_GUARD:{
-begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{
-"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{
-t.data._beginMatch!==e[1]&&t.ignoreMatch()}})});function k(e,t){
-"."===e.input[e.index-1]&&t.ignoreMatch()}function M(e,t){
-t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",
-e.__beforeBegin=k,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,
-void 0===e.relevance&&(e.relevance=0))}function O(e,t){
-Array.isArray(e.illegal)&&(e.illegal=((...e)=>"("+e.map((e=>g(e))).join("|")+")")(...e.illegal))
-}function A(e,t){if(e.match){
-if(e.begin||e.end)throw Error("begin & end are not supported with match")
-;e.begin=e.match,delete e.match}}function L(e,t){
-void 0===e.relevance&&(e.relevance=1)}
-const I=["of","and","for","in","not","or","if","then","parent","list","value"]
-;function j(e,t,n="keyword"){const i={}
-;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{
-Object.assign(i,j(e[n],t,n))})),i;function s(e,n){
-t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|")
-;i[n[0]]=[e,B(n[0],n[1])]}))}}function B(e,t){
-return t?Number(t):(e=>I.includes(e.toLowerCase()))(e)?0:1}
-function T(e,{plugins:t}){function n(t,n){
-return RegExp(g(t),"m"+(e.case_insensitive?"i":"")+(n?"g":""))}class i{
-constructor(){
-this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}
-addRule(e,t){
-t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),
-this.matchAt+=(e=>RegExp(e.toString()+"|").exec("").length-1)(e)+1}compile(){
-0===this.regexes.length&&(this.exec=()=>null)
-;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(((e,t="|")=>{let n=0
-;return e.map((e=>{n+=1;const t=n;let i=g(e),s="";for(;i.length>0;){
-const e=u.exec(i);if(!e){s+=i;break}
-s+=i.substring(0,e.index),i=i.substring(e.index+e[0].length),
-"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0],"("===e[0]&&n++)}return s
-})).map((e=>`(${e})`)).join(t)})(e),!0),this.lastIndex=0}exec(e){
-this.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e)
-;if(!t)return null
-;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n]
-;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){
-this.rules=[],this.multiRegexes=[],
-this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){
-if(this.multiRegexes[e])return this.multiRegexes[e];const t=new i
-;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))),
-t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){
-return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){
-this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){
-const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex
-;let n=t.exec(e)
-;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{
-const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)}
-return n&&(this.regexIndex+=n.position+1,
-this.regexIndex===this.count&&this.considerAll()),n}}
-if(e.compilerExtensions||(e.compilerExtensions=[]),
-e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language.  See documentation.")
-;return e.classNameAliases=a(e.classNameAliases||{}),function t(i,r){const l=i
-;if(i.isCompiled)return l
-;[A].forEach((e=>e(i,r))),e.compilerExtensions.forEach((e=>e(i,r))),
-i.__beforeBegin=null,[M,O,L].forEach((e=>e(i,r))),i.isCompiled=!0;let o=null
-;if("object"==typeof i.keywords&&(o=i.keywords.$pattern,
-delete i.keywords.$pattern),
-i.keywords&&(i.keywords=j(i.keywords,e.case_insensitive)),
-i.lexemes&&o)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ")
-;return o=o||i.lexemes||/\w+/,
-l.keywordPatternRe=n(o,!0),r&&(i.begin||(i.begin=/\B|\b/),
-l.beginRe=n(i.begin),i.endSameAsBegin&&(i.end=i.begin),
-i.end||i.endsWithParent||(i.end=/\B|\b/),
-i.end&&(l.endRe=n(i.end)),l.terminatorEnd=g(i.end)||"",
-i.endsWithParent&&r.terminatorEnd&&(l.terminatorEnd+=(i.end?"|":"")+r.terminatorEnd)),
-i.illegal&&(l.illegalRe=n(i.illegal)),
-i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>a(e,{
-variants:null},t)))),e.cachedVariants?e.cachedVariants:S(e)?a(e,{
-starts:e.starts?a(e.starts):null
-}):Object.isFrozen(e)?a(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{t(e,l)
-})),i.starts&&t(i.starts,r),l.matcher=(e=>{const t=new s
-;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin"
-}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end"
-}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(l),l}(e)}function S(e){
-return!!e&&(e.endsWithParent||S(e.starts))}function P(e){const t={
-props:["language","code","autodetect"],data:()=>({detectedLanguage:"",
-unknownLanguage:!1}),computed:{className(){
-return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){
-if(!this.autoDetect&&!e.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),
-this.unknownLanguage=!0,s(this.code);let t={}
-;return this.autoDetect?(t=e.highlightAuto(this.code),
-this.detectedLanguage=t.language):(t=e.highlight(this.language,this.code,this.ignoreIllegals),
-this.detectedLanguage=this.language),t.value},autoDetect(){
-return!(this.language&&(e=this.autodetect,!e&&""!==e));var e},
-ignoreIllegals:()=>!0},render(e){return e("pre",{},[e("code",{
-class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{
-Component:t,VuePlugin:{install(e){e.component("highlightjs",t)}}}}const D={
-"after:highlightElement":({el:e,result:t,text:n})=>{const i=H(e)
-;if(!i.length)return;const a=document.createElement("div")
-;a.innerHTML=t.value,t.value=((e,t,n)=>{let i=0,a="";const r=[];function l(){
-return e.length&&t.length?e[0].offset!==t[0].offset?e[0].offset<t[0].offset?e:t:"start"===t[0].event?e:t:e.length?e:t
-}function o(e){a+="<"+C(e)+[].map.call(e.attributes,(function(e){
-return" "+e.nodeName+'="'+s(e.value)+'"'})).join("")+">"}function c(e){
-a+="</"+C(e)+">"}function g(e){("start"===e.event?o:c)(e.node)}
-for(;e.length||t.length;){let t=l()
-;if(a+=s(n.substring(i,t[0].offset)),i=t[0].offset,t===e){r.reverse().forEach(c)
-;do{g(t.splice(0,1)[0]),t=l()}while(t===e&&t.length&&t[0].offset===i)
-;r.reverse().forEach(o)
-}else"start"===t[0].event?r.push(t[0].node):r.pop(),g(t.splice(0,1)[0])}
-return a+s(n.substr(i))})(i,H(a),n)}};function C(e){
-return e.nodeName.toLowerCase()}function H(e){const t=[];return function e(n,i){
-for(let s=n.firstChild;s;s=s.nextSibling)3===s.nodeType?i+=s.nodeValue.length:1===s.nodeType&&(t.push({
-event:"start",offset:i,node:s}),i=e(s,i),C(s).match(/br|hr|img|input/)||t.push({
-event:"stop",offset:i,node:s}));return i}(e,0),t}const $={},U=e=>{
-console.error(e)},z=(e,...t)=>{console.log("WARN: "+e,...t)},K=(e,t)=>{
-$[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),$[`${e}/${t}`]=!0)
-},G=s,V=a,W=Symbol("nomatch");return(e=>{
-const n=Object.create(null),s=Object.create(null),a=[];let r=!0
-;const l=/(^(<[^>]+>|\t|)+|\n)/gm,o="Could not find the language '{}', did you forget to load/include a language module?",g={
-disableAutodetect:!0,name:"Plain text",contains:[]};let u={
-noHighlightRe:/^(no-?highlight)$/i,
-languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",
-tabReplace:null,useBR:!1,languages:null,__emitter:c};function h(e){
-return u.noHighlightRe.test(e)}function d(e,t,n,i){let s="",a=""
-;"object"==typeof t?(s=e,
-n=t.ignoreIllegals,a=t.language,i=void 0):(K("10.7.0","highlight(lang, code, ...args) has been deprecated."),
-K("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),
-a=e,s=t);const r={code:s,language:a};M("before:highlight",r)
-;const l=r.result?r.result:f(r.language,r.code,n,i)
-;return l.code=r.code,M("after:highlight",l),l}function f(e,t,s,l){
-function c(e,t){const n=v.case_insensitive?t[0].toLowerCase():t[0]
-;return Object.prototype.hasOwnProperty.call(e.keywords,n)&&e.keywords[n]}
-function g(){null!=R.subLanguage?(()=>{if(""===M)return;let e=null
-;if("string"==typeof R.subLanguage){
-if(!n[R.subLanguage])return void k.addText(M)
-;e=f(R.subLanguage,M,!0,_[R.subLanguage]),_[R.subLanguage]=e.top
-}else e=p(M,R.subLanguage.length?R.subLanguage:null)
-;R.relevance>0&&(O+=e.relevance),k.addSublanguage(e.emitter,e.language)
-})():(()=>{if(!R.keywords)return void k.addText(M);let e=0
-;R.keywordPatternRe.lastIndex=0;let t=R.keywordPatternRe.exec(M),n="";for(;t;){
-n+=M.substring(e,t.index);const i=c(R,t);if(i){const[e,s]=i
-;if(k.addText(n),n="",O+=s,e.startsWith("_"))n+=t[0];else{
-const n=v.classNameAliases[e]||e;k.addKeyword(t[0],n)}}else n+=t[0]
-;e=R.keywordPatternRe.lastIndex,t=R.keywordPatternRe.exec(M)}
-n+=M.substr(e),k.addText(n)})(),M=""}function h(e){
-return e.className&&k.openNode(v.classNameAliases[e.className]||e.className),
-R=Object.create(e,{parent:{value:R}}),R}function d(e,t,n){let s=((e,t)=>{
-const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(s){if(e["on:end"]){
-const n=new i(e);e["on:end"](t,n),n.isMatchIgnored&&(s=!1)}if(s){
-for(;e.endsParent&&e.parent;)e=e.parent;return e}}
-if(e.endsWithParent)return d(e.parent,t,n)}function m(e){
-return 0===R.matcher.regexIndex?(M+=e[0],1):(I=!0,0)}function b(e){
-const n=e[0],i=t.substr(e.index),s=d(R,e,i);if(!s)return W;const a=R
-;a.skip?M+=n:(a.returnEnd||a.excludeEnd||(M+=n),g(),a.excludeEnd&&(M=n));do{
-R.className&&k.closeNode(),R.skip||R.subLanguage||(O+=R.relevance),R=R.parent
-}while(R!==s.parent)
-;return s.starts&&(s.endSameAsBegin&&(s.starts.endRe=s.endRe),
-h(s.starts)),a.returnEnd?0:n.length}let E={};function x(n,a){const l=a&&a[0]
-;if(M+=n,null==l)return g(),0
-;if("begin"===E.type&&"end"===a.type&&E.index===a.index&&""===l){
-if(M+=t.slice(a.index,a.index+1),!r){const t=Error("0 width match regex")
-;throw t.languageName=e,t.badRule=E.rule,t}return 1}
-if(E=a,"begin"===a.type)return function(e){
-const t=e[0],n=e.rule,s=new i(n),a=[n.__beforeBegin,n["on:begin"]]
-;for(const n of a)if(n&&(n(e,s),s.isMatchIgnored))return m(t)
-;return n&&n.endSameAsBegin&&(n.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),
-n.skip?M+=t:(n.excludeBegin&&(M+=t),
-g(),n.returnBegin||n.excludeBegin||(M=t)),h(n),n.returnBegin?0:t.length}(a)
-;if("illegal"===a.type&&!s){
-const e=Error('Illegal lexeme "'+l+'" for mode "'+(R.className||"<unnamed>")+'"')
-;throw e.mode=R,e}if("end"===a.type){const e=b(a);if(e!==W)return e}
-if("illegal"===a.type&&""===l)return 1
-;if(L>1e5&&L>3*a.index)throw Error("potential infinite loop, way more iterations than matches")
-;return M+=l,l.length}const v=N(e)
-;if(!v)throw U(o.replace("{}",e)),Error('Unknown language: "'+e+'"')
-;const w=T(v,{plugins:a});let y="",R=l||w;const _={},k=new u.__emitter(u);(()=>{
-const e=[];for(let t=R;t!==v;t=t.parent)t.className&&e.unshift(t.className)
-;e.forEach((e=>k.openNode(e)))})();let M="",O=0,A=0,L=0,I=!1;try{
-for(R.matcher.considerAll();;){
-L++,I?I=!1:R.matcher.considerAll(),R.matcher.lastIndex=A
-;const e=R.matcher.exec(t);if(!e)break;const n=x(t.substring(A,e.index),e)
-;A=e.index+n}return x(t.substr(A)),k.closeAllNodes(),k.finalize(),y=k.toHTML(),{
-relevance:Math.floor(O),value:y,language:e,illegal:!1,emitter:k,top:R}}catch(n){
-if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{
-msg:n.message,context:t.slice(A-100,A+100),mode:n.mode},sofar:y,relevance:0,
-value:G(t),emitter:k};if(r)return{illegal:!1,relevance:0,value:G(t),emitter:k,
-language:e,top:R,errorRaised:n};throw n}}function p(e,t){
-t=t||u.languages||Object.keys(n);const i=(e=>{const t={relevance:0,
-emitter:new u.__emitter(u),value:G(e),illegal:!1,top:g}
-;return t.emitter.addText(e),t})(e),s=t.filter(N).filter(k).map((t=>f(t,e,!1)))
-;s.unshift(i);const a=s.sort(((e,t)=>{
-if(e.relevance!==t.relevance)return t.relevance-e.relevance
-;if(e.language&&t.language){if(N(e.language).supersetOf===t.language)return 1
-;if(N(t.language).supersetOf===e.language)return-1}return 0})),[r,l]=a,o=r
-;return o.second_best=l,o}const m={"before:highlightElement":({el:e})=>{
-u.useBR&&(e.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/<br[ /]*>/g,"\n"))
-},"after:highlightElement":({result:e})=>{
-u.useBR&&(e.value=e.value.replace(/\n/g,"<br>"))}},b=/^(<[^>]+>|\t)+/gm,E={
-"after:highlightElement":({result:e})=>{
-u.tabReplace&&(e.value=e.value.replace(b,(e=>e.replace(/\t/g,u.tabReplace))))}}
-;function x(e){let t=null;const n=(e=>{let t=e.className+" "
-;t+=e.parentNode?e.parentNode.className:"";const n=u.languageDetectRe.exec(t)
-;if(n){const t=N(n[1])
-;return t||(z(o.replace("{}",n[1])),z("Falling back to no-highlight mode for this block.",e)),
-t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>h(e)||N(e)))})(e)
-;if(h(n))return;M("before:highlightElement",{el:e,language:n}),t=e
-;const i=t.textContent,a=n?d(i,{language:n,ignoreIllegals:!0}):p(i)
-;M("after:highlightElement",{el:e,result:a,text:i
-}),e.innerHTML=a.value,((e,t,n)=>{const i=t?s[t]:n
-;e.classList.add("hljs"),i&&e.classList.add(i)})(e,n,a.language),e.result={
-language:a.language,re:a.relevance,relavance:a.relevance
-},a.second_best&&(e.second_best={language:a.second_best.language,
-re:a.second_best.relevance,relavance:a.second_best.relevance})}const v=()=>{
-v.called||(v.called=!0,
-K("10.6.0","initHighlighting() is deprecated.  Use highlightAll() instead."),
-document.querySelectorAll("pre code").forEach(x))};let w=!1;function y(){
-"loading"!==document.readyState?document.querySelectorAll("pre code").forEach(x):w=!0
-}function N(e){return e=(e||"").toLowerCase(),n[e]||n[s[e]]}
-function R(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{
-s[e.toLowerCase()]=t}))}function k(e){const t=N(e)
-;return t&&!t.disableAutodetect}function M(e,t){const n=e;a.forEach((e=>{
-e[n]&&e[n](t)}))}
-"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{
-w&&y()}),!1),Object.assign(e,{highlight:d,highlightAuto:p,highlightAll:y,
-fixMarkup:e=>{
-return K("10.2.0","fixMarkup will be removed entirely in v11.0"),K("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),
-t=e,
-u.tabReplace||u.useBR?t.replace(l,(e=>"\n"===e?u.useBR?"<br>":e:u.tabReplace?e.replace(/\t/g,u.tabReplace):e)):t
-;var t},highlightElement:x,
-highlightBlock:e=>(K("10.7.0","highlightBlock will be removed entirely in v12.0"),
-K("10.7.0","Please use highlightElement now."),x(e)),configure:e=>{
-e.useBR&&(K("10.3.0","'useBR' will be removed entirely in v11.0"),
-K("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),
-u=V(u,e)},initHighlighting:v,initHighlightingOnLoad:()=>{
-K("10.6.0","initHighlightingOnLoad() is deprecated.  Use highlightAll() instead."),
-w=!0},registerLanguage:(t,i)=>{let s=null;try{s=i(e)}catch(e){
-if(U("Language definition for '{}' could not be registered.".replace("{}",t)),
-!r)throw e;U(e),s=g}
-s.name||(s.name=t),n[t]=s,s.rawDefinition=i.bind(null,e),s.aliases&&R(s.aliases,{
-languageName:t})},unregisterLanguage:e=>{delete n[e]
-;for(const t of Object.keys(s))s[t]===e&&delete s[t]},
-listLanguages:()=>Object.keys(n),getLanguage:N,registerAliases:R,
-requireLanguage:e=>{
-K("10.4.0","requireLanguage will be removed entirely in v11."),
-K("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844")
-;const t=N(e);if(t)return t
-;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},
-autoDetection:k,inherit:V,addPlugin:e=>{(e=>{
-e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{
-e["before:highlightBlock"](Object.assign({block:t.el},t))
-}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{
-e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),a.push(e)},
-vuePlugin:P(e).VuePlugin}),e.debugMode=()=>{r=!1},e.safeMode=()=>{r=!0
-},e.versionString="10.7.2";for(const e in _)"object"==typeof _[e]&&t(_[e])
-;return Object.assign(e,_),e.addPlugin(m),e.addPlugin(D),e.addPlugin(E),e})({})
-}();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);
-hljs.registerLanguage("1c",(()=>{"use strict";return s=>{
-var x="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",n="\u0434\u0430\u043b\u0435\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c\u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0434\u043b\u044f \u0435\u0441\u043b\u0438 \u0438 \u0438\u0437 \u0438\u043b\u0438 \u0438\u043d\u0430\u0447\u0435 \u0438\u043d\u0430\u0447\u0435\u0435\u0441\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043f\u044b\u0442\u043a\u0438 \u043a\u043e\u043d\u0435\u0446\u0446\u0438\u043a\u043b\u0430 \u043d\u0435 \u043d\u043e\u0432\u044b\u0439 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043f\u0435\u0440\u0435\u043c \u043f\u043e \u043f\u043e\u043a\u0430 \u043f\u043e\u043f\u044b\u0442\u043a\u0430 \u043f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u0442\u043e\u0433\u0434\u0430 \u0446\u0438\u043a\u043b \u044d\u043a\u0441\u043f\u043e\u0440\u0442 ",e="null \u0438\u0441\u0442\u0438\u043d\u0430 \u043b\u043e\u0436\u044c \u043d\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043e",o=s.inherit(s.NUMBER_MODE),t={
-className:"string",begin:'"|\\|',end:'"|$',contains:[{begin:'""'}]},a={
-begin:"'",end:"'",excludeBegin:!0,excludeEnd:!0,contains:[{className:"number",
-begin:"\\d{4}([\\.\\\\/:-]?\\d{2}){0,5}"}]},m=s.inherit(s.C_LINE_COMMENT_MODE)
-;return{name:"1C:Enterprise",case_insensitive:!0,keywords:{$pattern:x,keyword:n,
-built_in:"\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u043e\u043a \u0441\u0438\u043c\u0432\u043e\u043b\u0442\u0430\u0431\u0443\u043b\u044f\u0446\u0438\u0438 ansitooem oemtoansi \u0432\u0432\u0435\u0441\u0442\u0438\u0432\u0438\u0434\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0438\u043e\u0434 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0434\u0430\u0442\u0430\u0433\u043e\u0434 \u0434\u0430\u0442\u0430\u043c\u0435\u0441\u044f\u0446 \u0434\u0430\u0442\u0430\u0447\u0438\u0441\u043b\u043e \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0438\u0431 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u0434\u0441\u0438\u043c\u0432 \u043a\u043e\u043d\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u043d\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043a\u043e\u043d\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u043d\u0435\u0434\u0435\u043b\u0438 \u043b\u043e\u0433 \u043b\u043e\u043310 \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043d\u0430\u0431\u043e\u0440\u0430\u043f\u0440\u0430\u0432 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0432\u0438\u0434 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0441\u0447\u0435\u0442 \u043d\u0430\u0439\u0442\u0438\u0441\u0441\u044b\u043b\u043a\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043d\u0430\u0447\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u0433\u043e\u0434\u0430 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u043d\u0435\u0434\u0435\u043b\u0438\u0433\u043e\u0434\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u044f\u0437\u044b\u043a \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043e\u043a\u043d\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 \u043f\u0435\u0440\u0438\u043e\u0434\u0441\u0442\u0440 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u0442\u0443\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0430 \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u043f\u0438\u0441\u044c \u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u043c \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043f\u043e \u0441\u0438\u043c\u0432 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430 \u0441\u0442\u0440\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u0447\u0435\u0442\u043f\u043e\u043a\u043e\u0434\u0443 \u0442\u0435\u043a\u0443\u0449\u0435\u0435\u0432\u0440\u0435\u043c\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0442\u0440 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043d\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043f\u043e \u0444\u0438\u043a\u0441\u0448\u0430\u0431\u043b\u043e\u043d \u0448\u0430\u0431\u043b\u043e\u043d acos asin atan base64\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 base64\u0441\u0442\u0440\u043e\u043a\u0430 cos exp log log10 pow sin sqrt tan xml\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 xml\u0441\u0442\u0440\u043e\u043a\u0430 xml\u0442\u0438\u043f xml\u0442\u0438\u043f\u0437\u043d\u0447 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0435\u043e\u043a\u043d\u043e \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u043b\u0435\u0432\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u0434\u0430\u0442\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u0441\u0442\u0440\u043e\u043a\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0447\u0438\u0441\u043b\u043e \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u0447\u0442\u0435\u043d\u0438\u044fxml \u0432\u043e\u043f\u0440\u043e\u0441 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0440\u0435\u0433 \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u043f\u0440\u0430\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0432\u044b\u0447\u0438\u0441\u043b\u0438\u0442\u044c \u0433\u043e\u0434 \u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b\u0432\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u0442\u0430 \u0434\u0435\u043d\u044c \u0434\u0435\u043d\u044c\u0433\u043e\u0434\u0430 \u0434\u0435\u043d\u044c\u043d\u0435\u0434\u0435\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c\u043c\u0435\u0441\u044f\u0446 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0437\u0430\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cjson \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cxml \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u0437\u0430\u043f\u0438\u0441\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0432\u043e\u0439\u0441\u0442\u0432 \u0437\u0430\u043f\u0440\u043e\u0441\u0438\u0442\u044c\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u0441\u0438\u0441\u0442\u0435\u043c\u0443 \u0437\u0430\u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0444\u0430\u0439\u043b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0438\u0437xml\u0442\u0438\u043f\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u043c\u043e\u0434\u0435\u043b\u0438xdto \u0438\u043c\u044f\u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430 \u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438\u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0433\u043e\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043a\u043e\u0434\u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u0430\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043a\u043e\u043d\u0435\u0446\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u0434\u043d\u044f \u043a\u043e\u043d\u0435\u0446\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0438\u043d\u0443\u0442\u044b \u043a\u043e\u043d\u0435\u0446\u043d\u0435\u0434\u0435\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u0447\u0430\u0441\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0444\u0430\u0439\u043b \u043a\u0440\u0430\u0442\u043a\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043b\u0435\u0432 \u043c\u0430\u043a\u0441 \u043c\u0435\u0441\u0442\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u043c\u0435\u0441\u044f\u0446 \u043c\u0438\u043d \u043c\u0438\u043d\u0443\u0442\u0430 \u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043d\u0430\u0439\u0442\u0438 \u043d\u0430\u0439\u0442\u0438\u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u043d\u0430\u0439\u0442\u0438\u043e\u043a\u043d\u043e\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u043c\u0435\u0447\u0435\u043d\u043d\u044b\u0435\u043d\u0430\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u0441\u0441\u044b\u043b\u043a\u0430\u043c \u043d\u0430\u0439\u0442\u0438\u0444\u0430\u0439\u043b\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u0434\u043d\u044f \u043d\u0430\u0447\u0430\u043b\u043e\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0438\u043d\u0443\u0442\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0447\u0430\u0441\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0443\u0441\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0438\u0441\u043a\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0435\u0434\u0435\u043b\u044f\u0433\u043e\u0434\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u044c\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043d\u043e\u043c\u0435\u0440\u0441\u0435\u0430\u043d\u0441\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u043e\u043c\u0435\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u0440\u0435\u0433 \u043d\u0441\u0442\u0440 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044e\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u0435\u0440\u044b\u0432\u0430\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043e\u043a\u0440 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c\u043e\u0431\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0438\u043d\u0434\u0435\u043a\u0441\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0435\u0440\u0435\u0439\u0442\u0438\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0434\u0430\u0442\u044b \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0447\u0438\u0441\u043b\u0430 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u043e\u043f\u0440\u043e\u0441 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043d\u0430\u043a\u0430\u0440\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044ccom\u043e\u0431\u044a\u0435\u043a\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044cxml\u0442\u0438\u043f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0430\u0434\u0440\u0435\u0441\u043f\u043e\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043a\u043e\u0434\u044b\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0447\u0430\u0441\u043e\u0432\u044b\u0435\u043f\u043e\u044f\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u044d\u043a\u0440\u0430\u043d\u043e\u0432\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0430\u0434\u0440\u0435\u0441\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0438\u0439\u043c\u0430\u043a\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0443\u044e\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043a\u043d\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u0443\u044e\u043e\u0442\u043c\u0435\u0442\u043a\u0443\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445\u0441\u0441\u044b\u043b\u043e\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0435\u0430\u043d\u0441\u044b\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0443\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0441\u0435\u0430\u043d\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u043e\u0441 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0432\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u0440\u0430\u0432 \u043f\u0440\u0430\u0432\u043e\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u0434\u0430\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0430\u0432\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0447\u0430\u0441\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u044f\u0441\u0430 \u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043a\u0440\u0430\u0442\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c\u0432\u044b\u0437\u043e\u0432 \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cjson \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cxml \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u043f\u0443\u0441\u0442\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0440\u0430\u0431\u043e\u0447\u0438\u0439\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u0440\u0430\u0437\u043e\u0440\u0432\u0430\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0440\u043e\u043b\u044c\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0441\u0435\u043a\u0443\u043d\u0434\u0430 \u0441\u0438\u0433\u043d\u0430\u043b \u0441\u0438\u043c\u0432\u043e\u043b \u0441\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u043b\u0435\u0442\u043d\u0435\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0431\u0443\u0444\u0435\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043a\u0430\u0442\u0430\u043b\u043e\u0433 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u0444\u0430\u0431\u0440\u0438\u043a\u0443xdto \u0441\u043e\u043a\u0440\u043b \u0441\u043e\u043a\u0440\u043b\u043f \u0441\u043e\u043a\u0440\u043f \u0441\u043e\u043e\u0431\u0449\u0438\u0442\u044c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0440\u0435\u0434 \u0441\u0442\u0440\u0434\u043b\u0438\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043a\u0430\u043d\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043c\u0435\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u043d\u0430\u0439\u0442\u0438 \u0441\u0442\u0440\u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442\u0441\u044f\u0441 \u0441\u0442\u0440\u043e\u043a\u0430 \u0441\u0442\u0440\u043e\u043a\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0441\u0442\u0440\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0441\u0442\u0440\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u0440\u0430\u0432\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0432\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0442\u0440\u0448\u0430\u0431\u043b\u043e\u043d \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430\u0441\u0435\u0430\u043d\u0441\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u0432\u043c\u0438\u043b\u043b\u0438\u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0442\u0438\u043f \u0442\u0438\u043f\u0437\u043d\u0447 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f\u0430\u043a\u0442\u0438\u0432\u043d\u0430 \u0442\u0440\u0435\u0433 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0444\u043e\u0440\u043c\u0430\u0442 \u0446\u0435\u043b \u0447\u0430\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0447\u0438\u0441\u043b\u043e \u0447\u0438\u0441\u043b\u043e\u043f\u0440\u043e\u043f\u0438\u0441\u044c\u044e \u044d\u0442\u043e\u0430\u0434\u0440\u0435\u0441\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 ws\u0441\u0441\u044b\u043b\u043a\u0438 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043c\u0430\u043a\u0435\u0442\u043e\u0432\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u0441\u0442\u0438\u043b\u0435\u0439 \u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u044b \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0442\u0447\u0435\u0442\u044b \u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u043a\u0443\u043f\u043a\u0438 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0441\u0442\u0438\u043b\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0436\u0443\u0440\u043d\u0430\u043b\u044b\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u0437\u0430\u0434\u0430\u0447\u0438 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0439\u0434\u0430\u0442\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u043d\u0441\u0442\u0430\u043d\u0442\u044b \u043a\u0440\u0438\u0442\u0435\u0440\u0438\u0438\u043e\u0442\u0431\u043e\u0440\u0430 \u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u043e\u0442\u0447\u0435\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u044c\u0437\u0430\u0434\u0430\u0447\u043e\u0441 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044f \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u043f\u043b\u0430\u043d\u044b\u043e\u0431\u043c\u0435\u043d\u0430 \u043f\u043b\u0430\u043d\u044b\u0441\u0447\u0435\u0442\u043e\u0432 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0440\u0430\u0431\u043e\u0447\u0430\u044f\u0434\u0430\u0442\u0430 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0433\u0435\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043c\u0443\u043b\u044c\u0442\u0438\u043c\u0435\u0434\u0438\u0430 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043f\u043e\u0447\u0442\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0438\u0438 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0435\u043f\u043e\u0442\u043e\u043a\u0438 \u0444\u043e\u043d\u043e\u0432\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0432\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043e\u0431\u0449\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438\u0445\u0441\u043f\u0438\u0441\u043a\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0441\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a ",
-class:"web\u0446\u0432\u0435\u0442\u0430 windows\u0446\u0432\u0435\u0442\u0430 windows\u0448\u0440\u0438\u0444\u0442\u044b \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0440\u0430\u043c\u043a\u0438\u0441\u0442\u0438\u043b\u044f \u0441\u0438\u043c\u0432\u043e\u043b\u044b \u0446\u0432\u0435\u0442\u0430\u0441\u0442\u0438\u043b\u044f \u0448\u0440\u0438\u0444\u0442\u044b\u0441\u0442\u0438\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044f\u0432\u0444\u043e\u0440\u043c\u0435 \u0430\u0432\u0442\u043e\u0440\u0430\u0437\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0438\u0439 \u0430\u043d\u0438\u043c\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0438\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u043e\u0432 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0432\u044b\u0441\u043e\u0442\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u0430\u044f\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0435\u043a\u043e\u0440\u0430\u0446\u0438\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0438\u0434\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044f \u0432\u0438\u0434\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u0432\u0438\u0434\u043f\u043e\u043b\u044f\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0444\u043b\u0430\u0436\u043a\u0430 \u0432\u043b\u0438\u044f\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043d\u0430\u043f\u0443\u0437\u044b\u0440\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043a\u043e\u043b\u043e\u043d\u043e\u043a \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0433\u0440\u0443\u043f\u043f\u044b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u043c\u0435\u0436\u0434\u0443\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043c\u0438\u0444\u043e\u0440\u043c\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u044b\u0432\u043e\u0434\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043e\u0441\u044b\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u043e\u0447\u043a\u0438\u0431\u0438\u0440\u0436\u0435\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043e\u0441\u0438\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043e\u043c\u0430\u043d\u0434 \u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c\u0441\u0435\u0440\u0438\u0439 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0434\u0435\u0440\u0435\u0432\u0430 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u043f\u0438\u0441\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043d\u043e\u043f\u043e\u043a \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438\u0432\u044b\u0431\u043e\u0440\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u0441\u0443\u0436\u0434\u0435\u043d\u0438\u0439\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u043e\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u044f\u043f\u0440\u0438\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0442\u043a\u0438\u043f\u043e\u043b\u043e\u0441\u044b\u0440\u0435\u0433\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0444\u0438\u0433\u0443\u0440\u044b\u043a\u043d\u043e\u043f\u043a\u0438 \u043f\u0430\u043b\u0438\u0442\u0440\u0430\u0446\u0432\u0435\u0442\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0438\u0441\u043a\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043e\u043f\u043e\u0440\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u0448\u043a\u0430\u043b\u044b\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u043e\u0438\u0441\u043a\u043e\u043c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0439\u0433\u0438\u0441\u0442\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0441\u0435\u0440\u0438\u0439\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0437\u043c\u0435\u0440\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0441\u0442\u044f\u0433\u0438\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0432\u0432\u043e\u0434\u0430\u0441\u0442\u0440\u043e\u043a\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0431\u043e\u0440\u0430\u043d\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u0442\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0441\u0432\u044f\u0437\u0430\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u043f\u0435\u0447\u0430\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0440\u0435\u0436\u0438\u043c\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u043a\u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u043e\u043a\u043d\u0430\u0444\u043e\u0440\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0435\u0440\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438\u0441\u0435\u0442\u043a\u0438\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u043e\u0441\u0442\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0431\u0435\u043b\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435 \u0440\u0435\u0436\u0438\u043c\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043a\u043e\u043b\u043e\u043d\u043a\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0441\u043f\u0438\u0441\u043a\u0430\u0437\u0430\u0434\u0430\u0447 \u0441\u043a\u0432\u043e\u0437\u043d\u043e\u0435\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u043f\u043e\u0441\u043e\u0431\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0438\u0432\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0433\u0440\u0443\u043f\u043f\u0430\u043a\u043e\u043c\u0430\u043d\u0434 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0435\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0442\u0438\u043b\u044c\u0441\u0442\u0440\u0435\u043b\u043a\u0438 \u0442\u0438\u043f\u0430\u043f\u043f\u0440\u043e\u043a\u0441\u0438\u043c\u0430\u0446\u0438\u0438\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0442\u0438\u043f\u0438\u043c\u043f\u043e\u0440\u0442\u0430\u0441\u0435\u0440\u0438\u0439\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0438\u0438\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u0447\u043d\u043e\u0433\u043e\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0448\u043a\u0430\u043b\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0438\u0441\u043a\u0430\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u0440\u043e\u0435\u043a\u0446\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u043c\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u043e\u0441\u0435\u0440\u0438\u044f\u043c\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u0442\u0438\u043f\u0441\u0442\u043e\u0440\u043e\u043d\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0448\u043a\u0430\u043b\u044b\u0440\u0430\u0434\u0430\u0440\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0430\u043a\u0442\u043e\u0440\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0438\u0433\u0443\u0440\u0430\u043a\u043d\u043e\u043f\u043a\u0438 \u0444\u0438\u0433\u0443\u0440\u044b\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435 \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u043d\u044f\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0444\u043e\u0440\u043c\u0430\u0442\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0448\u0438\u0440\u0438\u043d\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0442\u043e\u0447\u043a\u0438\u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u0436\u0438\u043c\u0430\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0440\u0435\u0437\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u0432\u0440\u0435\u043c\u044f \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0430\u0432\u0442\u043e\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0439 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u043d\u043e\u043c\u0435\u0440\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043b\u043e\u043d\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u0441\u0442\u0440\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u043e\u0442\u043d\u043e\u0441\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u0447\u0442\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0434\u0432\u0443\u0441\u0442\u043e\u0440\u043e\u043d\u043d\u0435\u0439\u043f\u0435\u0447\u0430\u0442\u0438 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043a\u0443\u0440\u0441\u043e\u0440\u043e\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u044f\u0447\u0435\u0439\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043b\u0438\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0443\u0437\u043e\u0440\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u043e\u0447\u043d\u043e\u0441\u0442\u044c\u043f\u0435\u0447\u0430\u0442\u0438 \u0447\u0435\u0440\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0431\u0445\u043e\u0434\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0437\u0430\u043f\u0438\u0441\u0438\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0442\u043e\u0433\u043e\u0432 \u0434\u043e\u0441\u0442\u0443\u043f\u043a\u0444\u0430\u0439\u043b\u0443 \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u0430\u0439\u043b\u0430 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0434\u0430\u043d\u043d\u044b\u0445\u0430\u043d\u0430\u043b\u0438\u0437\u0430 \u043c\u0435\u0442\u043e\u0434\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0447\u0438\u0441\u043b\u043e\u0432\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0434\u0435\u0440\u0435\u0432\u043e\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0430\u044f\u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0430 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u043c\u043e\u0434\u0435\u043b\u0438\u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u0442\u0438\u043f\u043c\u0435\u0440\u044b\u0440\u0430\u0441\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0442\u0441\u0435\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u043f\u043e\u043b\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u0438\u0437\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u0448\u0430\u0431\u043b\u043e\u043d\u043e\u0432\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u0440\u043e\u0449\u0435\u043d\u0438\u044f\u0434\u0435\u0440\u0435\u0432\u0430\u0440\u0435\u0448\u0435\u043d\u0438\u0439 ws\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442xpathxs \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u0430\u0442\u044bjson \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u0432\u0438\u0434\u0444\u0430\u0441\u0435\u0442\u0430xdto \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044fdom \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u0445\u0435\u043c\u044bxs \u0437\u0430\u043f\u0440\u0435\u0449\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043dxs \u043c\u0435\u0442\u043e\u0434\u043d\u0430\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u044fxs \u043c\u043e\u0434\u0435\u043b\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430xml \u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u043e\u0442\u0431\u043e\u0440\u0430\u0443\u0437\u043b\u043e\u0432dom \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0441\u0442\u0440\u043e\u043ajson \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0435dom \u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u0442\u0438\u043f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xml \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fjson \u0442\u0438\u043f\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043exml \u0442\u0438\u043f\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044bxs \u0442\u0438\u043f\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438xml \u0442\u0438\u043f\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430domxpath \u0442\u0438\u043f\u0443\u0437\u043b\u0430dom \u0442\u0438\u043f\u0443\u0437\u043b\u0430xml \u0444\u043e\u0440\u043c\u0430xml \u0444\u043e\u0440\u043c\u0430\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044fxs \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u0430\u0442\u044bjson \u044d\u043a\u0440\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432json \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u043e\u0436\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u0435\u0439\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u043e\u0433\u043e\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0432\u044b\u0432\u043e\u0434\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u043f\u043e\u043b\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u043d\u0430\u0431\u043e\u0440\u043e\u0432\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u0432\u0442\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0443\u0441\u043b\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0432\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043d\u0435ascii\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0442\u0435\u043a\u0441\u0442\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u044b \u0441\u0442\u0430\u0442\u0443\u0441\u0440\u0430\u0437\u0431\u043e\u0440\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0442\u0438\u043f\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0438\u043c\u0435\u043d\u0444\u0430\u0439\u043b\u043e\u0432\u0432zip\u0444\u0430\u0439\u043b\u0435 \u043c\u0435\u0442\u043e\u0434\u0441\u0436\u0430\u0442\u0438\u044fzip \u043c\u0435\u0442\u043e\u0434\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044fzip \u0440\u0435\u0436\u0438\u043c\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439\u0444\u0430\u0439\u043b\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u043f\u043e\u0434\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439zip \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0441\u0436\u0430\u0442\u0438\u044fzip \u0437\u0432\u0443\u043a\u043e\u0432\u043e\u0435\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u043a\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u043f\u043e\u0442\u043e\u043a\u0435 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0431\u0430\u0439\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0432\u0438\u0441\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0444\u043e\u043d\u043e\u0432\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u043f\u043e\u0434\u043f\u0438\u0441\u0447\u0438\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044fftp \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0440\u044f\u0434\u043a\u0430\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043c\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044c\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 http\u043c\u0435\u0442\u043e\u0434 \u0430\u0432\u0442\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0430\u0432\u0442\u043e\u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u043e\u0433\u043e\u044f\u0437\u044b\u043a\u0430 \u0432\u0438\u0434\u0438\u0435\u0440\u0430\u0440\u0445\u0438\u0438 \u0432\u0438\u0434\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0438\u043d\u0434\u0435\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u0430\u0437\u044b\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u044b\u0441\u0442\u0440\u043e\u0433\u043e\u0432\u044b\u0431\u043e\u0440\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0437\u0434\u0435\u043b\u044f\u0435\u043c\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u043e\u0435\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0437\u0430\u0434\u0430\u0447\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043b\u0430\u043d\u0430\u043e\u0431\u043c\u0435\u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0433\u0440\u0430\u043d\u0438\u0446\u044b\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u043c\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u043d\u043e\u0441\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u0430 \u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u044b\u0445\u0432\u044b\u0437\u043e\u0432\u043e\u0432\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b\u0438\u0432\u043d\u0435\u0448\u043d\u0438\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0433\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0444\u043e\u0440\u043c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0439\u0434\u0430\u0442\u044b\u043d\u0430\u0447\u0430\u043b\u0430 \u0432\u0438\u0434\u0433\u0440\u0430\u043d\u0438\u0446\u044b \u0432\u0438\u0434\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0432\u0438\u0434\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0432\u0438\u0434\u0440\u0430\u043c\u043a\u0438 \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0446\u0432\u0435\u0442\u0430 \u0432\u0438\u0434\u0447\u0438\u0441\u043b\u043e\u0432\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u0430\u044f\u0434\u043b\u0438\u043d\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u0437\u043d\u0430\u043a \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435byteordermark \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043a\u043b\u0430\u0432\u0438\u0448\u0430 \u043a\u043e\u0434\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430\u0434\u0438\u0430\u043b\u043e\u0433\u0430 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430xbase \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0438\u0441\u043a\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u043e\u043f\u0440\u043e\u0441 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u043a\u0440\u0443\u0433\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u043e\u0440\u043c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430windows \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0442\u0438\u043f\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u043a\u043b\u0430\u0432\u0438\u0448\u0438enter \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438\u043e\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0438\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0437\u043e\u043b\u044f\u0446\u0438\u0438\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439 \u0445\u0435\u0448\u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0447\u0430\u0441\u0442\u0438\u0434\u0430\u0442\u044b",
-type:"com\u043e\u0431\u044a\u0435\u043a\u0442 ftp\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 http\u0437\u0430\u043f\u0440\u043e\u0441 http\u0441\u0435\u0440\u0432\u0438\u0441\u043e\u0442\u0432\u0435\u0442 http\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 ws\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f ws\u043f\u0440\u043e\u043a\u0441\u0438 xbase \u0430\u043d\u0430\u043b\u0438\u0437\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u043d\u043d\u043e\u0442\u0430\u0446\u0438\u044fxs \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u0444\u0435\u0440\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435xs \u0432\u044b\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0433\u0435\u043d\u0435\u0440\u0430\u0442\u043e\u0440\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0445\u0447\u0438\u0441\u0435\u043b \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0435\u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u043e\u0434\u0435\u043b\u0438xs \u0434\u0430\u043d\u043d\u044b\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430\u0433\u0430\u043d\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0446\u0432\u0435\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u044f\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442dom \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442html \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044fxs \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0438\u0441\u044cdom \u0437\u0430\u043f\u0438\u0441\u044cfastinfoset \u0437\u0430\u043f\u0438\u0441\u044chtml \u0437\u0430\u043f\u0438\u0441\u044cjson \u0437\u0430\u043f\u0438\u0441\u044cxml \u0437\u0430\u043f\u0438\u0441\u044czip\u0444\u0430\u0439\u043b\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0442\u0435\u043a\u0441\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0443\u0437\u043b\u043e\u0432dom \u0437\u0430\u043f\u0440\u043e\u0441 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435openssl \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u0435\u0439\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0437\u0432\u043b\u0435\u0447\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0438\u043c\u043f\u043e\u0440\u0442xs \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439\u043f\u0440\u043e\u0444\u0438\u043b\u044c \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u0440\u043e\u043a\u0441\u0438 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0434\u043b\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044fxs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0442\u0435\u0440\u0430\u0442\u043e\u0440\u0443\u0437\u043b\u043e\u0432dom \u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0430 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0430\u0442\u044b \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0447\u0438\u0441\u043b\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043c\u0430\u043a\u0435\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u0444\u043e\u0440\u043c\u0430\u0442\u043d\u043e\u0439\u0441\u0442\u0440\u043e\u043a\u0438 \u043b\u0438\u043d\u0438\u044f \u043c\u0430\u043a\u0435\u0442\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u0441\u043a\u0430xs \u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043d\u0430\u0431\u043e\u0440\u0441\u0445\u0435\u043cxml \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438json \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u0445\u043e\u0434\u0434\u0435\u0440\u0435\u0432\u0430dom \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u043d\u043e\u0442\u0430\u0446\u0438\u0438xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430xs \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0434\u043e\u0441\u0442\u0443\u043f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u043e\u0442\u043a\u0430\u0437\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0432\u0430\u0435\u043c\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0442\u0438\u043f\u043e\u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u043e\u0432xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430dom \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044fxpathxs \u043e\u0442\u0431\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u043a\u0435\u0442\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u044b\u0445\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438json \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438xml \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0447\u0442\u0435\u043d\u0438\u044fxml \u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435xs \u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a \u043f\u043e\u043b\u0435\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044cdom \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0441\u0445\u0435\u043cxml \u043f\u043e\u0442\u043e\u043a \u043f\u043e\u0442\u043e\u043a\u0432\u043f\u0430\u043c\u044f\u0442\u0438 \u043f\u043e\u0447\u0442\u0430 \u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435xsl \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043a\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u043c\u0443xml \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u043a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0437\u044b\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043ddom \u0440\u0430\u043c\u043a\u0430 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0435\u0438\u043c\u044fxml \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0447\u0442\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0432\u043e\u0434\u043d\u0430\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u0444\u0430\u0439\u043b \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432\u0444\u0430\u0439\u043b \u0441\u0436\u0430\u0442\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u0441\u043e\u0447\u0435\u0442\u0430\u043d\u0438\u0435\u043a\u043b\u0430\u0432\u0438\u0448 \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u043d\u0430\u0447\u0430\u043b\u0430 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439\u043f\u0435\u0440\u0438\u043e\u0434 \u0441\u0445\u0435\u043c\u0430xml \u0441\u0445\u0435\u043c\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u0441\u0442\u0438\u0440\u0443\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445xml \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0439\u043f\u043e\u0442\u043e\u043a \u0444\u0430\u0441\u0435\u0442\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432\u0434\u0440\u043e\u0431\u043d\u043e\u0439\u0447\u0430\u0441\u0442\u0438xs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0440\u0430\u0437\u0446\u0430xs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0449\u0435\u0433\u043e\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432xs \u0444\u0430\u0441\u0435\u0442\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u0444\u0438\u043b\u044c\u0442\u0440\u0443\u0437\u043b\u043e\u0432dom \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0444\u0440\u0430\u0433\u043c\u0435\u043d\u0442xs \u0445\u0435\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442 \u0447\u0442\u0435\u043d\u0438\u0435fastinfoset \u0447\u0442\u0435\u043d\u0438\u0435html \u0447\u0442\u0435\u043d\u0438\u0435json \u0447\u0442\u0435\u043d\u0438\u0435xml \u0447\u0442\u0435\u043d\u0438\u0435zip\u0444\u0430\u0439\u043b\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0447\u0442\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0443\u0437\u043b\u043e\u0432dom \u0448\u0440\u0438\u0444\u0442 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 comsafearray \u0434\u0435\u0440\u0435\u0432\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043c\u0430\u0441\u0441\u0438\u0432 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0441\u043f\u0438\u0441\u043e\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u043c\u0430\u0441\u0441\u0438\u0432 ",
-literal:e},contains:[{className:"meta",begin:"#|&",end:"$",keywords:{$pattern:x,
-"meta-keyword":n+"\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0432\u0435\u0431\u043a\u043b\u0438\u0435\u043d\u0442 \u0432\u043c\u0435\u0441\u0442\u043e \u0432\u043d\u0435\u0448\u043d\u0435\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442 \u043a\u043e\u043d\u0435\u0446\u043e\u0431\u043b\u0430\u0441\u0442\u0438 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043b\u0438\u0435\u043d\u0442 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0432\u0435\u0440 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0434 \u043f\u043e\u0441\u043b\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0431\u044b\u0447\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043d\u043a\u0438\u0439\u043a\u043b\u0438\u0435\u043d\u0442 "
-},contains:[m]},{className:"function",variants:[{
-begin:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430|\u0444\u0443\u043d\u043a\u0446\u0438\u044f",
-end:"\\)",
-keywords:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f"
-},{
-begin:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b|\u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438",
-keywords:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b \u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438"
-}],contains:[{begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"params",
-begin:x,end:",",excludeEnd:!0,endsWithParent:!0,keywords:{$pattern:x,
-keyword:"\u0437\u043d\u0430\u0447",literal:e},contains:[o,t,a]},m]
-},s.inherit(s.TITLE_MODE,{begin:x})]},m,{className:"symbol",begin:"~",end:";|:",
-excludeEnd:!0},o,t,a]}}})());
-hljs.registerLanguage("abnf",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(a=e)?"string"==typeof a?a:a.source:null;var a
-})).join("")}return a=>{const s={ruleDeclaration:/^[a-zA-Z][a-zA-Z0-9-]*/,
-unexpectedChars:/[!@#$^&',?+~`|:]/},n=a.COMMENT(/;/,/$/),r={
-className:"attribute",begin:e(s.ruleDeclaration,/(?=\s*=)/)};return{
-name:"Augmented Backus-Naur Form",illegal:s.unexpectedChars,
-keywords:["ALPHA","BIT","CHAR","CR","CRLF","CTL","DIGIT","DQUOTE","HEXDIG","HTAB","LF","LWSP","OCTET","SP","VCHAR","WSP"],
-contains:[r,n,{className:"symbol",begin:/%b[0-1]+(-[0-1]+|(\.[0-1]+)+){0,1}/},{
-className:"symbol",begin:/%d[0-9]+(-[0-9]+|(\.[0-9]+)+){0,1}/},{
-className:"symbol",begin:/%x[0-9A-F]+(-[0-9A-F]+|(\.[0-9A-F]+)+){0,1}/},{
-className:"symbol",begin:/%[si]/},a.QUOTE_STRING_MODE,a.NUMBER_MODE]}}})());
-hljs.registerLanguage("accesslog",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}function a(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const l=["GET","POST","HEAD","PUT","DELETE","CONNECT","OPTIONS","PATCH","TRACE"]
-;return{name:"Apache Access Log",contains:[{className:"number",
-begin:/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?\b/,relevance:5},{
-className:"number",begin:/\b\d+\b/,relevance:0},{className:"string",
-begin:n(/"/,a(...l)),end:/"/,keywords:l,illegal:/\n/,relevance:5,contains:[{
-begin:/HTTP\/[12]\.\d'/,relevance:5}]},{className:"string",
-begin:/\[\d[^\]\n]{8,}\]/,illegal:/\n/,relevance:1},{className:"string",
-begin:/\[/,end:/\]/,illegal:/\n/,relevance:0},{className:"string",
-begin:/"Mozilla\/\d\.\d \(/,end:/"/,illegal:/\n/,relevance:3},{
-className:"string",begin:/"/,end:/"/,illegal:/\n/,relevance:0}]}}})());
-hljs.registerLanguage("actionscript",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>({name:"ActionScript",aliases:["as"],keywords:{
-keyword:"as break case catch class const continue default delete do dynamic each else extends final finally for function get if implements import in include instanceof interface internal is namespace native new override package private protected public return set static super switch this throw try typeof use var void while with",
-literal:"true false null undefined"},
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.C_NUMBER_MODE,{
-className:"class",beginKeywords:"package",end:/\{/,contains:[n.TITLE_MODE]},{
-className:"class",beginKeywords:"class interface",end:/\{/,excludeEnd:!0,
-contains:[{beginKeywords:"extends implements"},n.TITLE_MODE]},{className:"meta",
-beginKeywords:"import include",end:/;/,keywords:{"meta-keyword":"import include"
-}},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,
-illegal:/\S/,contains:[n.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,{
-className:"rest_arg",begin:/[.]{3}/,end:/[a-zA-Z_$][a-zA-Z0-9_$]*/,relevance:10
-}]},{begin:e(/:\s*/,/([*]|[a-zA-Z_$][a-zA-Z0-9_$]*)/)}]},n.METHOD_GUARD],
-illegal:/#/})})());
-hljs.registerLanguage("ada",(()=>{"use strict";return e=>{
-const n="[A-Za-z](_?[A-Za-z0-9.])*",s="[]\\{\\}%#'\"",a=e.COMMENT("--","$"),r={
-begin:"\\s+:\\s+",end:"\\s*(:=|;|\\)|=>|$)",illegal:s,contains:[{
-beginKeywords:"loop for declare others",endsParent:!0},{className:"keyword",
-beginKeywords:"not null constant access function procedure in out aliased exception"
-},{className:"type",begin:n,endsParent:!0,relevance:0}]};return{name:"Ada",
-case_insensitive:!0,keywords:{
-keyword:"abort else new return abs elsif not reverse abstract end accept entry select access exception of separate aliased exit or some all others subtype and for out synchronized array function overriding at tagged generic package task begin goto pragma terminate body private then if procedure type case in protected constant interface is raise use declare range delay limited record when delta loop rem while digits renames with do mod requeue xor",
-literal:"True False"},contains:[a,{className:"string",begin:/"/,end:/"/,
-contains:[{begin:/""/,relevance:0}]},{className:"string",begin:/'.'/},{
-className:"number",
-begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",
-relevance:0},{className:"symbol",begin:"'"+n},{className:"title",
-begin:"(\\bwith\\s+)?(\\bprivate\\s+)?\\bpackage\\s+(\\bbody\\s+)?",
-end:"(is|$)",keywords:"package body",excludeBegin:!0,excludeEnd:!0,illegal:s},{
-begin:"(\\b(with|overriding)\\s+)?\\b(function|procedure)\\s+",
-end:"(\\bis|\\bwith|\\brenames|\\)\\s*;)",
-keywords:"overriding function procedure with is renames return",returnBegin:!0,
-contains:[a,{className:"title",
-begin:"(\\bwith\\s+)?\\b(function|procedure)\\s+",end:"(\\(|\\s+|$)",
-excludeBegin:!0,excludeEnd:!0,illegal:s},r,{className:"type",
-begin:"\\breturn\\s+",end:"(\\s+|;|$)",keywords:"return",excludeBegin:!0,
-excludeEnd:!0,endsParent:!0,illegal:s}]},{className:"type",
-begin:"\\b(sub)?type\\s+",end:"\\s+",keywords:"type",excludeBegin:!0,illegal:s
-},r]}}})());
-hljs.registerLanguage("angelscript",(()=>{"use strict";return e=>{var n={
-className:"built_in",
-begin:"\\b(void|bool|int|int8|int16|int32|int64|uint|uint8|uint16|uint32|uint64|string|ref|array|double|float|auto|dictionary)"
-},a={className:"symbol",begin:"[a-zA-Z0-9_]+@"},i={className:"keyword",
-begin:"<",end:">",contains:[n,a]};return n.contains=[i],a.contains=[i],{
-name:"AngelScript",aliases:["asc"],
-keywords:"for in|0 break continue while do|0 return if else case switch namespace is cast or and xor not get|0 in inout|10 out override set|0 private public const default|0 final shared external mixin|10 enum typedef funcdef this super import from interface abstract|0 try catch protected explicit property",
-illegal:"(^using\\s+[A-Za-z0-9_\\.]+;$|\\bfunction\\s*[^\\(])",contains:[{
-className:"string",begin:"'",end:"'",illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"""',
-end:'"""'},{className:"string",begin:'"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE],relevance:0
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-begin:"^\\s*\\[",end:"\\]"},{beginKeywords:"interface namespace",end:/\{/,
-illegal:"[;.\\-]",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+"}]},{
-beginKeywords:"class",end:/\{/,illegal:"[;.\\-]",contains:[{className:"symbol",
-begin:"[a-zA-Z0-9_]+",contains:[{begin:"[:,]\\s*",contains:[{className:"symbol",
-begin:"[a-zA-Z0-9_]+"}]}]}]},n,a,{className:"literal",
-begin:"\\b(null|true|false)"},{className:"number",relevance:0,
-begin:"(-?)(\\b0[xXbBoOdD][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?f?|\\.\\d+f?)([eE][-+]?\\d+f?)?)"
-}]}}})());
-hljs.registerLanguage("apache",(()=>{"use strict";return e=>{const n={
-className:"number",begin:/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?/}
-;return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0,
-contains:[e.HASH_COMMENT_MODE,{className:"section",begin:/<\/?/,end:/>/,
-contains:[n,{className:"number",begin:/:\d{1,5}/
-},e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute",
-begin:/\w+/,relevance:0,keywords:{
-nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"
-},starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"},
-contains:[{className:"meta",begin:/\s\[/,end:/\]$/},{className:"variable",
-begin:/[\$%]\{/,end:/\}/,contains:["self",{className:"number",begin:/[$%]\d+/}]
-},n,{className:"number",begin:/\d+/},e.QUOTE_STRING_MODE]}}],illegal:/\S/}}
-})());
-hljs.registerLanguage("applescript",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function t(...t){
-return t.map((t=>e(t))).join("")}function n(...t){
-return"("+t.map((t=>e(t))).join("|")+")"}return e=>{
-const r=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),i={className:"params",
-begin:/\(/,end:/\)/,contains:["self",e.C_NUMBER_MODE,r]
-},o=e.COMMENT(/--/,/$/),a=[o,e.COMMENT(/\(\*/,/\*\)/,{contains:["self",o]
-}),e.HASH_COMMENT_MODE];return{name:"AppleScript",aliases:["osascript"],
-keywords:{
-keyword:"about above after against and around as at back before beginning behind below beneath beside between but by considering contain contains continue copy div does eighth else end equal equals error every exit fifth first for fourth from front get given global if ignoring in into is it its last local me middle mod my ninth not of on onto or over prop property put ref reference repeat returning script second set seventh since sixth some tell tenth that the|0 then third through thru timeout times to transaction try until where while whose with without",
-literal:"AppleScript false linefeed return pi quote result space tab true",
-built_in:"alias application boolean class constant date file integer list number real record string text activate beep count delay launch log offset read round run say summarize write character characters contents day frontmost id item length month name paragraph paragraphs rest reverse running time version weekday word words year"
-},contains:[r,e.C_NUMBER_MODE,{className:"built_in",
-begin:t(/\b/,n(/clipboard info/,/the clipboard/,/info for/,/list (disks|folder)/,/mount volume/,/path to/,/(close|open for) access/,/(get|set) eof/,/current date/,/do shell script/,/get volume settings/,/random number/,/set volume/,/system attribute/,/system info/,/time to GMT/,/(load|run|store) script/,/scripting components/,/ASCII (character|number)/,/localized string/,/choose (application|color|file|file name|folder|from list|remote application|URL)/,/display (alert|dialog)/),/\b/)
-},{className:"built_in",begin:/^\s*return\b/},{className:"literal",
-begin:/\b(text item delimiters|current application|missing value)\b/},{
-className:"keyword",
-begin:t(/\b/,n(/apart from/,/aside from/,/instead of/,/out of/,/greater than/,/isn't|(doesn't|does not) (equal|come before|come after|contain)/,/(greater|less) than( or equal)?/,/(starts?|ends|begins?) with/,/contained by/,/comes (before|after)/,/a (ref|reference)/,/POSIX (file|path)/,/(date|time) string/,/quoted form/),/\b/)
-},{beginKeywords:"on",illegal:/[${=;\n]/,contains:[e.UNDERSCORE_TITLE_MODE,i]
-},...a],illegal:/\/\/|->|=>|\[\[/}}})());
-hljs.registerLanguage("arcade",(()=>{"use strict";return e=>{
-const n="[A-Za-z_][0-9A-Za-z_]*",a={
-keyword:"if for while var new function do return void else break",
-literal:"BackSlash DoubleQuote false ForwardSlash Infinity NaN NewLine null PI SingleQuote Tab TextFormatting true undefined",
-built_in:"Abs Acos Angle Attachments Area AreaGeodetic Asin Atan Atan2 Average Bearing Boolean Buffer BufferGeodetic Ceil Centroid Clip Console Constrain Contains Cos Count Crosses Cut Date DateAdd DateDiff Day Decode DefaultValue Dictionary Difference Disjoint Distance DistanceGeodetic Distinct DomainCode DomainName Equals Exp Extent Feature FeatureSet FeatureSetByAssociation FeatureSetById FeatureSetByPortalItem FeatureSetByRelationshipName FeatureSetByTitle FeatureSetByUrl Filter First Floor Geometry GroupBy Guid HasKey Hour IIf IndexOf Intersection Intersects IsEmpty IsNan IsSelfIntersecting Length LengthGeodetic Log Max Mean Millisecond Min Minute Month MultiPartToSinglePart Multipoint NextSequenceValue Now Number OrderBy Overlaps Point Polygon Polyline Portal Pow Random Relate Reverse RingIsClockWise Round Second SetGeometry Sin Sort Sqrt Stdev Sum SymmetricDifference Tan Text Timestamp Today ToLocal Top Touches ToUTC TrackCurrentTime TrackGeometryWindow TrackIndex TrackStartTime TrackWindow TypeOf Union UrlEncode Variance Weekday When Within Year "
-},t={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{
-begin:"\\b(0[oO][0-7]+)"},{begin:e.C_NUMBER_RE}],relevance:0},i={
-className:"subst",begin:"\\$\\{",end:"\\}",keywords:a,contains:[]},r={
-className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,i]}
-;i.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,t,e.REGEXP_MODE]
-;const o=i.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE])
-;return{name:"ArcGIS Arcade",keywords:a,
-contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"symbol",
-begin:"\\$[datastore|feature|layer|map|measure|sourcefeature|sourcelayer|targetfeature|targetlayer|value|view]+"
-},t,{begin:/[{,]\s*/,relevance:0,contains:[{begin:n+"\\s*:",returnBegin:!0,
-relevance:0,contains:[{className:"attr",begin:n,relevance:0}]}]},{
-begin:"("+e.RE_STARTERS_RE+"|\\b(return)\\b)\\s*",keywords:"return",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{
-className:"function",begin:"(\\(.*?\\)|"+n+")\\s*=>",returnBegin:!0,
-end:"\\s*=>",contains:[{className:"params",variants:[{begin:n},{begin:/\(\s*\)/
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:a,contains:o}]}]
-}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,
-excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:n}),{className:"params",
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:o}],illegal:/\[|%/},{
-begin:/\$[(.]/}],illegal:/#(?!!)/}}})());
-hljs.registerLanguage("arduino",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return r=>{
-const n=(r=>{const n=r.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),i="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(i)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[r.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},r.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},r.inherit(o,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},n,r.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(i)+r.IDENT_RE,relevance:0
-},u=e(i)+r.IDENT_RE+"\\s*\\(",m={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
-keywords:m,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,r.IDENT_RE,(g=/\s*\(/,
-t("(?=",g,")")))};var g;const b=[p,c,s,n,r.C_BLOCK_COMMENT_MODE,l,o],_={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:b.concat([{
-begin:/\(/,end:/\)/,keywords:m,contains:b.concat(["self"]),relevance:0}]),
-relevance:0},y={className:"function",begin:"("+a+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[o,l]},{className:"params",begin:/\(/,end:/\)/,
-keywords:m,relevance:0,contains:[n,r.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,
-end:/\)/,keywords:m,relevance:0,contains:["self",n,r.C_BLOCK_COMMENT_MODE,o,l,s]
-}]},s,n,r.C_BLOCK_COMMENT_MODE,c]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(_,y,p,b,[c,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:m,contains:["self",s]},{begin:r.IDENT_RE+"::",keywords:m},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},r.TITLE_MODE]}]),exports:{
-preprocessor:c,strings:o,keywords:m}}})(r),i=n.keywords
-;return i.keyword+=" boolean byte word String",
-i.literal+=" DIGITAL_MESSAGE FIRMATA_STRING ANALOG_MESSAGE REPORT_DIGITAL REPORT_ANALOG INPUT_PULLUP SET_PIN_MODE INTERNAL2V56 SYSTEM_RESET LED_BUILTIN INTERNAL1V1 SYSEX_START INTERNAL EXTERNAL DEFAULT OUTPUT INPUT HIGH LOW",
-i.built_in+=" KeyboardController MouseController SoftwareSerial EthernetServer EthernetClient LiquidCrystal RobotControl GSMVoiceCall EthernetUDP EsploraTFT HttpClient RobotMotor WiFiClient GSMScanner FileSystem Scheduler GSMServer YunClient YunServer IPAddress GSMClient GSMModem Keyboard Ethernet Console GSMBand Esplora Stepper Process WiFiUDP GSM_SMS Mailbox USBHost Firmata PImage Client Server GSMPIN FileIO Bridge Serial EEPROM Stream Mouse Audio Servo File Task GPRS WiFi Wire TFT GSM SPI SD ",
-i._+=" setup loop runShellCommandAsynchronously analogWriteResolution retrieveCallingNumber printFirmwareVersion analogReadResolution sendDigitalPortPair noListenOnLocalhost readJoystickButton setFirmwareVersion readJoystickSwitch scrollDisplayRight getVoiceCallStatus scrollDisplayLeft writeMicroseconds delayMicroseconds beginTransmission getSignalStrength runAsynchronously getAsynchronously listenOnLocalhost getCurrentCarrier readAccelerometer messageAvailable sendDigitalPorts lineFollowConfig countryNameWrite runShellCommand readStringUntil rewindDirectory readTemperature setClockDivider readLightSensor endTransmission analogReference detachInterrupt countryNameRead attachInterrupt encryptionType readBytesUntil robotNameWrite readMicrophone robotNameRead cityNameWrite userNameWrite readJoystickY readJoystickX mouseReleased openNextFile scanNetworks noInterrupts digitalWrite beginSpeaker mousePressed isActionDone mouseDragged displayLogos noAutoscroll addParameter remoteNumber getModifiers keyboardRead userNameRead waitContinue processInput parseCommand printVersion readNetworks writeMessage blinkVersion cityNameRead readMessage setDataMode parsePacket isListening setBitOrder beginPacket isDirectory motorsWrite drawCompass digitalRead clearScreen serialEvent rightToLeft setTextSize leftToRight requestFrom keyReleased compassRead analogWrite interrupts WiFiServer disconnect playMelody parseFloat autoscroll getPINUsed setPINUsed setTimeout sendAnalog readSlider analogRead beginWrite createChar motorsStop keyPressed tempoWrite readButton subnetMask debugPrint macAddress writeGreen randomSeed attachGPRS readString sendString remotePort releaseAll mouseMoved background getXChange getYChange answerCall getResult voiceCall endPacket constrain getSocket writeJSON getButton available connected findUntil readBytes exitValue readGreen writeBlue startLoop IPAddress isPressed sendSysex pauseMode gatewayIP setCursor getOemKey tuneWrite noDisplay loadImage switchPIN onRequest onReceive changePIN playFile noBuffer parseInt overflow checkPIN knobRead beginTFT bitClear updateIR bitWrite position writeRGB highByte writeRed setSpeed readBlue noStroke remoteIP transfer shutdown hangCall beginSMS endWrite attached maintain noCursor checkReg checkPUK shiftOut isValid shiftIn pulseIn connect println localIP pinMode getIMEI display noBlink process getBand running beginSD drawBMP lowByte setBand release bitRead prepare pointTo readRed setMode noFill remove listen stroke detach attach noTone exists buffer height bitSet circle config cursor random IRread setDNS endSMS getKey micros millis begin print write ready flush width isPIN blink clear press mkdir rmdir close point yield image BSSID click delay read text move peek beep rect line open seek fill size turn stop home find step tone sqrt RSSI SSID end bit tan cos sin pow map abs max min get run put",
-n.name="Arduino",n.aliases=["ino"],n.supersetOf="cpp",n}})());
-hljs.registerLanguage("armasm",(()=>{"use strict";return s=>{const e={
-variants:[s.COMMENT("^[ \\t]*(?=#)","$",{relevance:0,excludeBegin:!0
-}),s.COMMENT("[;@]","$",{relevance:0
-}),s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE]};return{name:"ARM Assembly",
-case_insensitive:!0,aliases:["arm"],keywords:{$pattern:"\\.?"+s.IDENT_RE,
-meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .arm .thumb .code16 .code32 .force_thumb .thumb_func .ltorg ALIAS ALIGN ARM AREA ASSERT ATTR CN CODE CODE16 CODE32 COMMON CP DATA DCB DCD DCDU DCDO DCFD DCFDU DCI DCQ DCQU DCW DCWU DN ELIF ELSE END ENDFUNC ENDIF ENDP ENTRY EQU EXPORT EXPORTAS EXTERN FIELD FILL FUNCTION GBLA GBLL GBLS GET GLOBAL IF IMPORT INCBIN INCLUDE INFO KEEP LCLA LCLL LCLS LTORG MACRO MAP MEND MEXIT NOFP OPT PRESERVE8 PROC QN READONLY RELOC REQUIRE REQUIRE8 RLIST FN ROUT SETA SETL SETS SN SPACE SUBT THUMB THUMBX TTL WHILE WEND ",
-built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 pc lr sp ip sl sb fp a1 a2 a3 a4 v1 v2 v3 v4 v5 v6 v7 v8 f0 f1 f2 f3 f4 f5 f6 f7 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 p14 p15 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 q0 q1 q2 q3 q4 q5 q6 q7 q8 q9 q10 q11 q12 q13 q14 q15 cpsr_c cpsr_x cpsr_s cpsr_f cpsr_cx cpsr_cxs cpsr_xs cpsr_xsf cpsr_sf cpsr_cxsf spsr_c spsr_x spsr_s spsr_f spsr_cx spsr_cxs spsr_xs spsr_xsf spsr_sf spsr_cxsf s0 s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19 s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11 d12 d13 d14 d15 d16 d17 d18 d19 d20 d21 d22 d23 d24 d25 d26 d27 d28 d29 d30 d31 {PC} {VAR} {TRUE} {FALSE} {OPT} {CONFIG} {ENDIAN} {CODESIZE} {CPU} {FPU} {ARCHITECTURE} {PCSTOREOFFSET} {ARMASM_VERSION} {INTER} {ROPI} {RWPI} {SWST} {NOSWST} . @"
-},contains:[{className:"keyword",
-begin:"\\b(adc|(qd?|sh?|u[qh]?)?add(8|16)?|usada?8|(q|sh?|u[qh]?)?(as|sa)x|and|adrl?|sbc|rs[bc]|asr|b[lx]?|blx|bxj|cbn?z|tb[bh]|bic|bfc|bfi|[su]bfx|bkpt|cdp2?|clz|clrex|cmp|cmn|cpsi[ed]|cps|setend|dbg|dmb|dsb|eor|isb|it[te]{0,3}|lsl|lsr|ror|rrx|ldm(([id][ab])|f[ds])?|ldr((s|ex)?[bhd])?|movt?|mvn|mra|mar|mul|[us]mull|smul[bwt][bt]|smu[as]d|smmul|smmla|mla|umlaal|smlal?([wbt][bt]|d)|mls|smlsl?[ds]|smc|svc|sev|mia([bt]{2}|ph)?|mrr?c2?|mcrr2?|mrs|msr|orr|orn|pkh(tb|bt)|rbit|rev(16|sh)?|sel|[su]sat(16)?|nop|pop|push|rfe([id][ab])?|stm([id][ab])?|str(ex)?[bhd]?|(qd?)?sub|(sh?|q|u[qh]?)?sub(8|16)|[su]xt(a?h|a?b(16)?)|srs([id][ab])?|swpb?|swi|smi|tst|teq|wfe|wfi|yield)(eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al|hs|lo)?[sptrx]?(?=\\s)"
-},e,s.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0
-},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{
-className:"number",variants:[{begin:"[#$=]?0x[0-9a-f]+"},{begin:"[#$=]?0b[01]+"
-},{begin:"[#$=]\\d+"},{begin:"\\b\\d+"}],relevance:0},{className:"symbol",
-variants:[{begin:"^[ \\t]*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{
-begin:"^[a-z_\\.\\$][a-z0-9_\\.\\$]+"},{begin:"[=#]\\w+"}],relevance:0}]}}})());
-hljs.registerLanguage("xml",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")}
-function a(...n){return n.map((n=>e(n))).join("")}function s(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const t=a(/[A-Z_]/,a("(",/[A-Z0-9_.-]*:/,")?"),/[A-Z0-9_.-]*/),i={
-className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},r={begin:/\s/,
-contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]
-},c=e.inherit(r,{begin:/\(/,end:/\)/}),l=e.inherit(e.APOS_STRING_MODE,{
-className:"meta-string"}),g=e.inherit(e.QUOTE_STRING_MODE,{
-className:"meta-string"}),m={endsWithParent:!0,illegal:/</,relevance:0,
-contains:[{className:"attr",begin:/[A-Za-z0-9._:-]+/,relevance:0},{begin:/=\s*/,
-relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,
-end:/"/,contains:[i]},{begin:/'/,end:/'/,contains:[i]},{begin:/[^\s"'=<>`]+/}]}]
-}]};return{name:"HTML, XML",
-aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],
-case_insensitive:!0,contains:[{className:"meta",begin:/<![a-z]/,end:/>/,
-relevance:10,contains:[r,g,l,c,{begin:/\[/,end:/\]/,contains:[{className:"meta",
-begin:/<![a-z]/,end:/>/,contains:[r,c,g,l]}]}]},e.COMMENT(/<!--/,/-->/,{
-relevance:10}),{begin:/<!\[CDATA\[/,end:/\]\]>/,relevance:10},i,{
-className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",
-begin:/<style(?=\s|>)/,end:/>/,keywords:{name:"style"},contains:[m],starts:{
-end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",
-begin:/<script(?=\s|>)/,end:/>/,keywords:{name:"script"},contains:[m],starts:{
-end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{
-className:"tag",begin:/<>|<\/>/},{className:"tag",
-begin:a(/</,n(a(t,s(/\/>/,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",
-begin:t,relevance:0,starts:m}]},{className:"tag",begin:a(/<\//,n(a(t,/>/))),
-contains:[{className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,
-endsParent:!0}]}]}}})());
-hljs.registerLanguage("asciidoc",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a=[{className:"strong",begin:/\*{2}([^\n]+?)\*{2}/
-},{className:"strong",
-begin:e(/\*\*/,/((\*(?!\*)|\\[^\n]|[^*\n\\])+\n)+/,/(\*(?!\*)|\\[^\n]|[^*\n\\])*/,/\*\*/),
-relevance:0},{className:"strong",begin:/\B\*(\S|\S[^\n]*?\S)\*(?!\w)/},{
-className:"strong",begin:/\*[^\s]([^\n]+\n)+([^\n]+)\*/}],s=[{
-className:"emphasis",begin:/_{2}([^\n]+?)_{2}/},{className:"emphasis",
-begin:e(/__/,/((_(?!_)|\\[^\n]|[^_\n\\])+\n)+/,/(_(?!_)|\\[^\n]|[^_\n\\])*/,/__/),
-relevance:0},{className:"emphasis",begin:/\b_(\S|\S[^\n]*?\S)_(?!\w)/},{
-className:"emphasis",begin:/_[^\s]([^\n]+\n)+([^\n]+)_/},{className:"emphasis",
-begin:"\\B'(?!['\\s])",end:"(\\n{2}|')",contains:[{begin:"\\\\'\\w",relevance:0
-}],relevance:0}];return{name:"AsciiDoc",aliases:["adoc"],
-contains:[n.COMMENT("^/{4,}\\n","\\n/{4,}$",{relevance:10
-}),n.COMMENT("^//","$",{relevance:0}),{className:"title",begin:"^\\.\\w.*$"},{
-begin:"^[=\\*]{4,}\\n",end:"\\n^[=\\*]{4,}$",relevance:10},{className:"section",
-relevance:10,variants:[{begin:"^(={1,6})[ \t].+?([ \t]\\1)?$"},{
-begin:"^[^\\[\\]\\n]+?\\n[=\\-~\\^\\+]{2,}$"}]},{className:"meta",
-begin:"^:.+?:",end:"\\s",excludeEnd:!0,relevance:10},{className:"meta",
-begin:"^\\[.+?\\]$",relevance:0},{className:"quote",begin:"^_{4,}\\n",
-end:"\\n_{4,}$",relevance:10},{className:"code",begin:"^[\\-\\.]{4,}\\n",
-end:"\\n[\\-\\.]{4,}$",relevance:10},{begin:"^\\+{4,}\\n",end:"\\n\\+{4,}$",
-contains:[{begin:"<",end:">",subLanguage:"xml",relevance:0}],relevance:10},{
-className:"bullet",begin:"^(\\*+|-+|\\.+|[^\\n]+?::)\\s+"},{className:"symbol",
-begin:"^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\\s+",relevance:10},{
-begin:/\\[*_`]/},{begin:/\\\\\*{2}[^\n]*?\*{2}/},{begin:/\\\\_{2}[^\n]*_{2}/},{
-begin:/\\\\`{2}[^\n]*`{2}/},{begin:/[:;}][*_`](?![*_`])/},...a,...s,{
-className:"string",variants:[{begin:"``.+?''"},{begin:"`.+?'"}]},{
-className:"code",begin:/`{2}/,end:/(\n{2}|`{2})/},{className:"code",
-begin:"(`.+?`|\\+.+?\\+)",relevance:0},{className:"code",begin:"^[ \\t]",
-end:"$",relevance:0},{begin:"^'{3,}[ \\t]*$",relevance:10},{
-begin:"(link:)?(http|https|ftp|file|irc|image:?):\\S+?\\[[^[]*?\\]",
-returnBegin:!0,contains:[{begin:"(link|image:?):",relevance:0},{
-className:"link",begin:"\\w",end:"[^\\[]+",relevance:0},{className:"string",
-begin:"\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0,relevance:0}],relevance:10}]
-}}})());
-hljs.registerLanguage("aspectj",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{
-const t="false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance",i="get set args call"
-;return{name:"AspectJ",keywords:t,illegal:/<\/|#/,
-contains:[n.COMMENT(/\/\*\*/,/\*\//,{relevance:0,contains:[{begin:/\w+@/,
-relevance:0},{className:"doctag",begin:/@[A-Za-z]+/}]
-}),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{
-className:"class",beginKeywords:"aspect",end:/[{;=]/,excludeEnd:!0,
-illegal:/[:;"\[\]]/,contains:[{
-beginKeywords:"extends implements pertypewithin perthis pertarget percflowbelow percflow issingleton"
-},n.UNDERSCORE_TITLE_MODE,{begin:/\([^\)]*/,end:/[)]+/,keywords:t+" "+i,
-excludeEnd:!1}]},{className:"class",beginKeywords:"class interface",end:/[{;=]/,
-excludeEnd:!0,relevance:0,keywords:"class interface",illegal:/[:"\[\]]/,
-contains:[{beginKeywords:"extends implements"},n.UNDERSCORE_TITLE_MODE]},{
-beginKeywords:"pointcut after before around throwing returning",end:/[)]/,
-excludeEnd:!1,illegal:/["\[\]]/,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),returnBegin:!0,
-contains:[n.UNDERSCORE_TITLE_MODE]}]},{begin:/[:]/,returnBegin:!0,end:/[{;]/,
-relevance:0,excludeEnd:!1,keywords:t,illegal:/["\[\]]/,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),keywords:t+" "+i,relevance:0
-},n.QUOTE_STRING_MODE]},{beginKeywords:"new throw",relevance:0},{
-className:"function",
-begin:/\w+ +\w+(\.\w+)?\s*\([^\)]*\)\s*((throws)[\w\s,]+)?[\{;]/,returnBegin:!0,
-end:/[{;=]/,keywords:t,excludeEnd:!0,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),returnBegin:!0,relevance:0,
-contains:[n.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,
-relevance:0,keywords:t,
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_NUMBER_MODE,n.C_BLOCK_COMMENT_MODE]
-},n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE]},n.C_NUMBER_MODE,{
-className:"meta",begin:/@[A-Za-z]+/}]}}})());
-hljs.registerLanguage("autohotkey",(()=>{"use strict";return e=>{const a={
-begin:"`[\\s\\S]"};return{name:"AutoHotkey",case_insensitive:!0,aliases:["ahk"],
-keywords:{
-keyword:"Break Continue Critical Exit ExitApp Gosub Goto New OnExit Pause return SetBatchLines SetTimer Suspend Thread Throw Until ahk_id ahk_class ahk_pid ahk_exe ahk_group",
-literal:"true false NOT AND OR",
-built_in:"ComSpec Clipboard ClipboardAll ErrorLevel"},
-contains:[a,e.inherit(e.QUOTE_STRING_MODE,{contains:[a]}),e.COMMENT(";","$",{
-relevance:0}),e.C_BLOCK_COMMENT_MODE,{className:"number",begin:e.NUMBER_RE,
-relevance:0},{className:"variable",begin:"%[a-zA-Z0-9#_$@]+%"},{
-className:"built_in",begin:"^\\s*\\w+\\s*(,|%)"},{className:"title",variants:[{
-begin:'^[^\\n";]+::(?!=)'},{begin:'^[^\\n";]+:(?!=)',relevance:0}]},{
-className:"meta",begin:"^\\s*#\\w+",end:"$",relevance:0},{className:"built_in",
-begin:"A_[a-zA-Z0-9]+"},{begin:",\\s*,"}]}}})());
-hljs.registerLanguage("autoit",(()=>{"use strict";return e=>{const t={
-variants:[e.COMMENT(";","$",{relevance:0
-}),e.COMMENT("#cs","#ce"),e.COMMENT("#comments-start","#comments-end")]},r={
-begin:"\\$[A-z0-9_]+"},i={className:"string",variants:[{begin:/"/,end:/"/,
-contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,
-relevance:0}]}]},n={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]};return{
-name:"AutoIt",case_insensitive:!0,illegal:/\/\*/,keywords:{
-keyword:"ByRef Case Const ContinueCase ContinueLoop Dim Do Else ElseIf EndFunc EndIf EndSelect EndSwitch EndWith Enum Exit ExitLoop For Func Global If In Local Next ReDim Return Select Static Step Switch Then To Until Volatile WEnd While With",
-built_in:"Abs ACos AdlibRegister AdlibUnRegister Asc AscW ASin Assign ATan AutoItSetOption AutoItWinGetTitle AutoItWinSetTitle Beep Binary BinaryLen BinaryMid BinaryToString BitAND BitNOT BitOR BitRotate BitShift BitXOR BlockInput Break Call CDTray Ceiling Chr ChrW ClipGet ClipPut ConsoleRead ConsoleWrite ConsoleWriteError ControlClick ControlCommand ControlDisable ControlEnable ControlFocus ControlGetFocus ControlGetHandle ControlGetPos ControlGetText ControlHide ControlListView ControlMove ControlSend ControlSetText ControlShow ControlTreeView Cos Dec DirCopy DirCreate DirGetSize DirMove DirRemove DllCall DllCallAddress DllCallbackFree DllCallbackGetPtr DllCallbackRegister DllClose DllOpen DllStructCreate DllStructGetData DllStructGetPtr DllStructGetSize DllStructSetData DriveGetDrive DriveGetFileSystem DriveGetLabel DriveGetSerial DriveGetType DriveMapAdd DriveMapDel DriveMapGet DriveSetLabel DriveSpaceFree DriveSpaceTotal DriveStatus EnvGet EnvSet EnvUpdate Eval Execute Exp FileChangeDir FileClose FileCopy FileCreateNTFSLink FileCreateShortcut FileDelete FileExists FileFindFirstFile FileFindNextFile FileFlush FileGetAttrib FileGetEncoding FileGetLongName FileGetPos FileGetShortcut FileGetShortName FileGetSize FileGetTime FileGetVersion FileInstall FileMove FileOpen FileOpenDialog FileRead FileReadLine FileReadToArray FileRecycle FileRecycleEmpty FileSaveDialog FileSelectFolder FileSetAttrib FileSetEnd FileSetPos FileSetTime FileWrite FileWriteLine Floor FtpSetProxy FuncName GUICreate GUICtrlCreateAvi GUICtrlCreateButton GUICtrlCreateCheckbox GUICtrlCreateCombo GUICtrlCreateContextMenu GUICtrlCreateDate GUICtrlCreateDummy GUICtrlCreateEdit GUICtrlCreateGraphic GUICtrlCreateGroup GUICtrlCreateIcon GUICtrlCreateInput GUICtrlCreateLabel GUICtrlCreateList GUICtrlCreateListView GUICtrlCreateListViewItem GUICtrlCreateMenu GUICtrlCreateMenuItem GUICtrlCreateMonthCal GUICtrlCreateObj GUICtrlCreatePic GUICtrlCreateProgress GUICtrlCreateRadio GUICtrlCreateSlider GUICtrlCreateTab GUICtrlCreateTabItem GUICtrlCreateTreeView GUICtrlCreateTreeViewItem GUICtrlCreateUpdown GUICtrlDelete GUICtrlGetHandle GUICtrlGetState GUICtrlRead GUICtrlRecvMsg GUICtrlRegisterListViewSort GUICtrlSendMsg GUICtrlSendToDummy GUICtrlSetBkColor GUICtrlSetColor GUICtrlSetCursor GUICtrlSetData GUICtrlSetDefBkColor GUICtrlSetDefColor GUICtrlSetFont GUICtrlSetGraphic GUICtrlSetImage GUICtrlSetLimit GUICtrlSetOnEvent GUICtrlSetPos GUICtrlSetResizing GUICtrlSetState GUICtrlSetStyle GUICtrlSetTip GUIDelete GUIGetCursorInfo GUIGetMsg GUIGetStyle GUIRegisterMsg GUISetAccelerators GUISetBkColor GUISetCoord GUISetCursor GUISetFont GUISetHelp GUISetIcon GUISetOnEvent GUISetState GUISetStyle GUIStartGroup GUISwitch Hex HotKeySet HttpSetProxy HttpSetUserAgent HWnd InetClose InetGet InetGetInfo InetGetSize InetRead IniDelete IniRead IniReadSection IniReadSectionNames IniRenameSection IniWrite IniWriteSection InputBox Int IsAdmin IsArray IsBinary IsBool IsDeclared IsDllStruct IsFloat IsFunc IsHWnd IsInt IsKeyword IsNumber IsObj IsPtr IsString Log MemGetStats Mod MouseClick MouseClickDrag MouseDown MouseGetCursor MouseGetPos MouseMove MouseUp MouseWheel MsgBox Number ObjCreate ObjCreateInterface ObjEvent ObjGet ObjName OnAutoItExitRegister OnAutoItExitUnRegister Ping PixelChecksum PixelGetColor PixelSearch ProcessClose ProcessExists ProcessGetStats ProcessList ProcessSetPriority ProcessWait ProcessWaitClose ProgressOff ProgressOn ProgressSet Ptr Random RegDelete RegEnumKey RegEnumVal RegRead RegWrite Round Run RunAs RunAsWait RunWait Send SendKeepActive SetError SetExtended ShellExecute ShellExecuteWait Shutdown Sin Sleep SoundPlay SoundSetWaveVolume SplashImageOn SplashOff SplashTextOn Sqrt SRandom StatusbarGetText StderrRead StdinWrite StdioClose StdoutRead String StringAddCR StringCompare StringFormat StringFromASCIIArray StringInStr StringIsAlNum StringIsAlpha StringIsASCII StringIsDigit StringIsFloat StringIsInt StringIsLower StringIsSpace StringIsUpper StringIsXDigit StringLeft StringLen StringLower StringMid StringRegExp StringRegExpReplace StringReplace StringReverse StringRight StringSplit StringStripCR StringStripWS StringToASCIIArray StringToBinary StringTrimLeft StringTrimRight StringUpper Tan TCPAccept TCPCloseSocket TCPConnect TCPListen TCPNameToIP TCPRecv TCPSend TCPShutdown, UDPShutdown TCPStartup, UDPStartup TimerDiff TimerInit ToolTip TrayCreateItem TrayCreateMenu TrayGetMsg TrayItemDelete TrayItemGetHandle TrayItemGetState TrayItemGetText TrayItemSetOnEvent TrayItemSetState TrayItemSetText TraySetClick TraySetIcon TraySetOnEvent TraySetPauseIcon TraySetState TraySetToolTip TrayTip UBound UDPBind UDPCloseSocket UDPOpen UDPRecv UDPSend VarGetType WinActivate WinActive WinClose WinExists WinFlash WinGetCaretPos WinGetClassList WinGetClientSize WinGetHandle WinGetPos WinGetProcess WinGetState WinGetText WinGetTitle WinKill WinList WinMenuSelectItem WinMinimizeAll WinMinimizeAllUndo WinMove WinSetOnTop WinSetState WinSetTitle WinSetTrans WinWait WinWaitActive WinWaitClose WinWaitNotActive",
-literal:"True False And Null Not Or Default"},contains:[t,r,i,n,{
-className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":["EndRegion","forcedef","forceref","ignorefunc","include","include-once","NoTrayIcon","OnAutoItStartRegister","pragma","Region","RequireAdmin","Tidy_Off","Tidy_On","Tidy_Parameters"]
-},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",keywords:{
-"meta-keyword":"include"},end:"$",contains:[i,{className:"meta-string",
-variants:[{begin:"<",end:">"},{begin:/"/,end:/"/,contains:[{begin:/""/,
-relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]}]},i,t]
-},{className:"symbol",begin:"@[A-z0-9_]+"},{className:"function",
-beginKeywords:"Func",end:"$",illegal:"\\$|\\[|%",
-contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",
-contains:[r,i,n]}]}]}}})());
-hljs.registerLanguage("avrasm",(()=>{"use strict";return r=>({
-name:"AVR Assembly",case_insensitive:!0,keywords:{$pattern:"\\.?"+r.IDENT_RE,
-keyword:"adc add adiw and andi asr bclr bld brbc brbs brcc brcs break breq brge brhc brhs brid brie brlo brlt brmi brne brpl brsh brtc brts brvc brvs bset bst call cbi cbr clc clh cli cln clr cls clt clv clz com cp cpc cpi cpse dec eicall eijmp elpm eor fmul fmuls fmulsu icall ijmp in inc jmp ld ldd ldi lds lpm lsl lsr mov movw mul muls mulsu neg nop or ori out pop push rcall ret reti rjmp rol ror sbc sbr sbrc sbrs sec seh sbi sbci sbic sbis sbiw sei sen ser ses set sev sez sleep spm st std sts sub subi swap tst wdr",
-built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 r16 r17 r18 r19 r20 r21 r22 r23 r24 r25 r26 r27 r28 r29 r30 r31 x|0 xh xl y|0 yh yl z|0 zh zl ucsr1c udr1 ucsr1a ucsr1b ubrr1l ubrr1h ucsr0c ubrr0h tccr3c tccr3a tccr3b tcnt3h tcnt3l ocr3ah ocr3al ocr3bh ocr3bl ocr3ch ocr3cl icr3h icr3l etimsk etifr tccr1c ocr1ch ocr1cl twcr twdr twar twsr twbr osccal xmcra xmcrb eicra spmcsr spmcr portg ddrg ping portf ddrf sreg sph spl xdiv rampz eicrb eimsk gimsk gicr eifr gifr timsk tifr mcucr mcucsr tccr0 tcnt0 ocr0 assr tccr1a tccr1b tcnt1h tcnt1l ocr1ah ocr1al ocr1bh ocr1bl icr1h icr1l tccr2 tcnt2 ocr2 ocdr wdtcr sfior eearh eearl eedr eecr porta ddra pina portb ddrb pinb portc ddrc pinc portd ddrd pind spdr spsr spcr udr0 ucsr0a ucsr0b ubrr0l acsr admux adcsr adch adcl porte ddre pine pinf",
-meta:".byte .cseg .db .def .device .dseg .dw .endmacro .equ .eseg .exit .include .list .listmac .macro .nolist .org .set"
-},contains:[r.C_BLOCK_COMMENT_MODE,r.COMMENT(";","$",{relevance:0
-}),r.C_NUMBER_MODE,r.BINARY_NUMBER_MODE,{className:"number",
-begin:"\\b(\\$[a-zA-Z0-9]+|0o[0-7]+)"},r.QUOTE_STRING_MODE,{className:"string",
-begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"},{className:"symbol",
-begin:"^[A-Za-z0-9_.$]+:"},{className:"meta",begin:"#",end:"$"},{
-className:"subst",begin:"@[0-9]+"}]})})());
-hljs.registerLanguage("awk",(()=>{"use strict";return e=>({name:"Awk",keywords:{
-keyword:"BEGIN END if else while do for in break continue delete next nextfile function func exit|10"
-},contains:[{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{
-begin:/\$\{(.*?)\}/}]},{className:"string",contains:[e.BACKSLASH_ESCAPE],
-variants:[{begin:/(u|b)?r?'''/,end:/'''/,relevance:10},{begin:/(u|b)?r?"""/,
-end:/"""/,relevance:10},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{
-begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{
-begin:/(b|br)"/,end:/"/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},e.REGEXP_MODE,e.HASH_COMMENT_MODE,e.NUMBER_MODE]})})());
-hljs.registerLanguage("axapta",(()=>{"use strict";return e=>({name:"X++",
-aliases:["x++"],keywords:{
-keyword:["abstract","as","asc","avg","break","breakpoint","by","byref","case","catch","changecompany","class","client","client","common","const","continue","count","crosscompany","delegate","delete_from","desc","display","div","do","edit","else","eventhandler","exists","extends","final","finally","firstfast","firstonly","firstonly1","firstonly10","firstonly100","firstonly1000","flush","for","forceliterals","forcenestedloop","forceplaceholders","forceselectorder","forupdate","from","generateonly","group","hint","if","implements","in","index","insert_recordset","interface","internal","is","join","like","maxof","minof","mod","namespace","new","next","nofetch","notexists","optimisticlock","order","outer","pessimisticlock","print","private","protected","public","readonly","repeatableread","retry","return","reverse","select","server","setting","static","sum","super","switch","this","throw","try","ttsabort","ttsbegin","ttscommit","unchecked","update_recordset","using","validtimestate","void","where","while"],
-built_in:["anytype","boolean","byte","char","container","date","double","enum","guid","int","int64","long","real","short","str","utcdatetime","var"],
-literal:["default","false","null","true"]},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"},{className:"class",
-beginKeywords:"class interface",end:/\{/,excludeEnd:!0,illegal:":",contains:[{
-beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]}]})})());
-hljs.registerLanguage("bash",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(s=e)?"string"==typeof s?s:s.source:null;var s
-})).join("")}return s=>{const n={},t={begin:/\$\{/,end:/\}/,contains:["self",{
-begin:/:-/,contains:[n]}]};Object.assign(n,{className:"variable",variants:[{
-begin:e(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},t]});const a={
-className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},i={
-begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/,
-end:/(\w+)/,className:"string"})]}},c={className:"string",begin:/"/,end:/"/,
-contains:[s.BACKSLASH_ESCAPE,n,a]};a.contains.push(c);const o={begin:/\$\(\(/,
-end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,n]
-},r=s.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10
-}),l={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,
-contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{
-name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,
-keyword:"if then else elif fi for while in do done case esac function",
-literal:"true false",
-built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"
-},contains:[r,s.SHEBANG(),l,o,s.HASH_COMMENT_MODE,i,c,{className:"",begin:/\\"/
-},{className:"string",begin:/'/,end:/'/},n]}}})());
-hljs.registerLanguage("basic",(()=>{"use strict";return E=>({name:"BASIC",
-case_insensitive:!0,illegal:"^.",keywords:{$pattern:"[a-zA-Z][a-zA-Z0-9_$%!#]*",
-keyword:"ABS ASC AND ATN AUTO|0 BEEP BLOAD|10 BSAVE|10 CALL CALLS CDBL CHAIN CHDIR CHR$|10 CINT CIRCLE CLEAR CLOSE CLS COLOR COM COMMON CONT COS CSNG CSRLIN CVD CVI CVS DATA DATE$ DEFDBL DEFINT DEFSNG DEFSTR DEF|0 SEG USR DELETE DIM DRAW EDIT END ENVIRON ENVIRON$ EOF EQV ERASE ERDEV ERDEV$ ERL ERR ERROR EXP FIELD FILES FIX FOR|0 FRE GET GOSUB|10 GOTO HEX$ IF THEN ELSE|0 INKEY$ INP INPUT INPUT# INPUT$ INSTR IMP INT IOCTL IOCTL$ KEY ON OFF LIST KILL LEFT$ LEN LET LINE LLIST LOAD LOC LOCATE LOF LOG LPRINT USING LSET MERGE MID$ MKDIR MKD$ MKI$ MKS$ MOD NAME NEW NEXT NOISE NOT OCT$ ON OR PEN PLAY STRIG OPEN OPTION BASE OUT PAINT PALETTE PCOPY PEEK PMAP POINT POKE POS PRINT PRINT] PSET PRESET PUT RANDOMIZE READ REM RENUM RESET|0 RESTORE RESUME RETURN|0 RIGHT$ RMDIR RND RSET RUN SAVE SCREEN SGN SHELL SIN SOUND SPACE$ SPC SQR STEP STICK STOP STR$ STRING$ SWAP SYSTEM TAB TAN TIME$ TIMER TROFF TRON TO USR VAL VARPTR VARPTR$ VIEW WAIT WHILE WEND WIDTH WINDOW WRITE XOR"
-},contains:[E.QUOTE_STRING_MODE,E.COMMENT("REM","$",{relevance:10
-}),E.COMMENT("'","$",{relevance:0}),{className:"symbol",begin:"^[0-9]+ ",
-relevance:10},{className:"number",begin:"\\b\\d+(\\.\\d+)?([edED]\\d+)?[#!]?",
-relevance:0},{className:"number",begin:"(&[hH][0-9a-fA-F]{1,4})"},{
-className:"number",begin:"(&[oO][0-7]{1,6})"}]})})());
-hljs.registerLanguage("bnf",(()=>{"use strict";return e=>({
-name:"Backus\u2013Naur Form",contains:[{className:"attribute",begin:/</,end:/>/
-},{begin:/::=/,end:/$/,contains:[{begin:/</,end:/>/
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-}]})})());
-hljs.registerLanguage("brainfuck",(()=>{"use strict";return e=>{const n={
-className:"literal",begin:/[+-]/,relevance:0};return{name:"Brainfuck",
-aliases:["bf"],
-contains:[e.COMMENT("[^\\[\\]\\.,\\+\\-<> \r\n]","[\\[\\]\\.,\\+\\-<> \r\n]",{
-returnEnd:!0,relevance:0}),{className:"title",begin:"[\\[\\]]",relevance:0},{
-className:"string",begin:"[\\.,]",relevance:0},{begin:/(?:\+\+|--)/,contains:[n]
-},n]}}})());
-hljs.registerLanguage("c-like",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
-const a=(n=>{const a=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),r="[a-zA-Z_]\\w*::",s="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[n.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},a,n.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(r)+n.IDENT_RE,relevance:0
-},u=e(r)+n.IDENT_RE+"\\s*\\(",p={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},m={className:"function.dispatch",relevance:0,
-keywords:p,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
-t("(?=",_,")")))};var _;const g=[m,l,i,a,n.C_BLOCK_COMMENT_MODE,o,c],b={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:p,contains:g.concat([{
-begin:/\(/,end:/\)/,keywords:p,contains:g.concat(["self"]),relevance:0}]),
-relevance:0},f={className:"function",begin:"("+s+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:p,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:p,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:p,relevance:0,contains:[a,n.C_BLOCK_COMMENT_MODE,c,o,i,{begin:/\(/,
-end:/\)/,keywords:p,relevance:0,contains:["self",a,n.C_BLOCK_COMMENT_MODE,c,o,i]
-}]},i,a,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:p,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(b,f,m,g,[l,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:p,contains:["self",i]},{begin:n.IDENT_RE+"::",keywords:p},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
-preprocessor:l,strings:c,keywords:p}}})(n)
-;return a.disableAutodetect=!0,a.aliases=[],
-n.getLanguage("c")||a.aliases.push("c","h"),
-n.getLanguage("cpp")||a.aliases.push("cc","c++","h++","hpp","hh","hxx","cxx"),a}
-})());
-hljs.registerLanguage("c",(()=>{"use strict";function e(e){
-return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?")
-}return t=>{const n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),r="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},s={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[t.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},t.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},t.inherit(s,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},l={
-className:"title",begin:e(r)+t.IDENT_RE,relevance:0
-},d=e(r)+t.IDENT_RE+"\\s*\\(",u={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",
-literal:"true false nullptr NULL"},m=[c,i,n,t.C_BLOCK_COMMENT_MODE,o,s],p={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:u,contains:m.concat([{
-begin:/\(/,end:/\)/,keywords:u,contains:m.concat(["self"]),relevance:0}]),
-relevance:0},_={className:"function",begin:"("+a+"[\\*&\\s]+)+"+d,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:u,relevance:0},{begin:d,
-returnBegin:!0,contains:[l],relevance:0},{className:"params",begin:/\(/,
-end:/\)/,keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,s,o,i,{
-begin:/\(/,end:/\)/,keywords:u,relevance:0,
-contains:["self",n,t.C_BLOCK_COMMENT_MODE,s,o,i]}]
-},i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u,
-disableAutodetect:!0,illegal:"</",contains:[].concat(p,_,m,[c,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:u,contains:["self",i]},{begin:t.IDENT_RE+"::",keywords:u},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{
-preprocessor:c,strings:s,keywords:u}}}})());
-hljs.registerLanguage("cal",(()=>{"use strict";return e=>{
-const n="div mod in and or not xor asserterror begin case do downto else end exit for if of repeat then to until while with var",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{
-relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],r={className:"string",
-begin:/'/,end:/'/,contains:[{begin:/''/}]},s={className:"string",begin:/(#\d+)+/
-},i={className:"function",beginKeywords:"procedure",end:/[:;]/,
-keywords:"procedure|10",contains:[e.TITLE_MODE,{className:"params",begin:/\(/,
-end:/\)/,keywords:n,contains:[r,s]}].concat(a)},t={className:"class",
-begin:"OBJECT (Table|Form|Report|Dataport|Codeunit|XMLport|MenuSuite|Page|Query) (\\d+) ([^\\r\\n]+)",
-returnBegin:!0,contains:[e.TITLE_MODE,i]};return{name:"C/AL",
-case_insensitive:!0,keywords:{keyword:n,literal:"false true"},illegal:/\/\*/,
-contains:[r,s,{className:"number",begin:"\\b\\d+(\\.\\d+)?(DT|D|T)",relevance:0
-},{className:"string",begin:'"',end:'"'},e.NUMBER_MODE,t,i]}}})());
-hljs.registerLanguage("capnproto",(()=>{"use strict";return n=>({
-name:"Cap\u2019n Proto",aliases:["capnp"],keywords:{
-keyword:"struct enum interface union group import using const annotation extends in of on as with from fixed",
-built_in:"Void Bool Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 Float32 Float64 Text Data AnyPointer AnyStruct Capability List",
-literal:"true false"},
-contains:[n.QUOTE_STRING_MODE,n.NUMBER_MODE,n.HASH_COMMENT_MODE,{
-className:"meta",begin:/@0x[\w\d]{16};/,illegal:/\n/},{className:"symbol",
-begin:/@\d+\b/},{className:"class",beginKeywords:"struct enum",end:/\{/,
-illegal:/\n/,contains:[n.inherit(n.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]},{className:"class",beginKeywords:"interface",end:/\{/,
-illegal:/\n/,contains:[n.inherit(n.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]}]})})());
-hljs.registerLanguage("ceylon",(()=>{"use strict";return e=>{
-const a="assembly module package import alias class interface object given value assign void function new of extends satisfies abstracts in out return break continue throw assert dynamic if else switch case for while try catch finally then let this outer super is exists nonempty",s={
-className:"subst",excludeBegin:!0,excludeEnd:!0,begin:/``/,end:/``/,keywords:a,
-relevance:10},n=[{className:"string",begin:'"""',end:'"""',relevance:10},{
-className:"string",begin:'"',end:'"',contains:[s]},{className:"string",
-begin:"'",end:"'"},{className:"number",
-begin:"#[0-9a-fA-F_]+|\\$[01_]+|[0-9_]+(?:\\.[0-9_](?:[eE][+-]?\\d+)?)?[kMGTPmunpf]?",
-relevance:0}];return s.contains=n,{name:"Ceylon",keywords:{
-keyword:a+" shared abstract formal default actual variable late native deprecated final sealed annotation suppressWarnings small",
-meta:"doc by license see throws tagged"},illegal:"\\$[^01]|#[^0-9a-fA-F]",
-contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"]}),{
-className:"meta",begin:'@[a-z]\\w*(?::"[^"]*")?'}].concat(n)}}})());
-hljs.registerLanguage("clean",(()=>{"use strict";return e=>({name:"Clean",
-aliases:["icl","dcl"],keywords:{
-keyword:"if let in with where case of class instance otherwise implementation definition system module from import qualified as special code inline foreign export ccall stdcall generic derive infix infixl infixr",
-built_in:"Int Real Char Bool",literal:"True False"},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-begin:"->|<-[|:]?|#!?|>>=|\\{\\||\\|\\}|:==|=:|<>"}]})})());
-hljs.registerLanguage("clojure",(()=>{"use strict";return e=>{
-const t="a-zA-Z_\\-!.?+*=<>&#'",n="["+t+"]["+t+"0-9/;:]*",r="def defonce defprotocol defstruct defmulti defmethod defn- defn defmacro deftype defrecord",a={
-$pattern:n,
-"builtin-name":r+" cond apply if-not if-let if not not= =|0 <|0 >|0 <=|0 >=|0 ==|0 +|0 /|0 *|0 -|0 rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy first rest cons cast coll last butlast sigs reify second ffirst fnext nfirst nnext meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"
-},s={begin:n,relevance:0},o={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",
-relevance:0},i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),c=e.COMMENT(";","$",{relevance:0}),d={className:"literal",
-begin:/\b(true|false|nil)\b/},l={begin:"[\\[\\{]",end:"[\\]\\}]"},m={
-className:"comment",begin:"\\^"+n},p=e.COMMENT("\\^\\{","\\}"),u={
-className:"symbol",begin:"[:]{1,2}"+n},f={begin:"\\(",end:"\\)"},h={
-endsWithParent:!0,relevance:0},y={keywords:a,className:"name",begin:n,
-relevance:0,starts:h},g=[f,i,m,p,c,u,l,o,d,s],b={beginKeywords:r,lexemes:n,
-end:'(\\[|#|\\d|"|:|\\{|\\)|\\(|$)',contains:[{className:"title",begin:n,
-relevance:0,excludeEnd:!0,endsParent:!0}].concat(g)}
-;return f.contains=[e.COMMENT("comment",""),b,y,h],
-h.contains=g,l.contains=g,p.contains=[l],{name:"Clojure",aliases:["clj"],
-illegal:/\S/,contains:[f,i,m,p,c,u,l,o,d]}}})());
-hljs.registerLanguage("clojure-repl",(()=>{"use strict";return e=>({
-name:"Clojure REPL",contains:[{className:"meta",begin:/^([\w.-]+|\s*#_)?=>/,
-starts:{end:/$/,subLanguage:"clojure"}}]})})());
-hljs.registerLanguage("cmake",(()=>{"use strict";return e=>({name:"CMake",
-aliases:["cmake.in"],case_insensitive:!0,keywords:{
-keyword:"break cmake_host_system_information cmake_minimum_required cmake_parse_arguments cmake_policy configure_file continue elseif else endforeach endfunction endif endmacro endwhile execute_process file find_file find_library find_package find_path find_program foreach function get_cmake_property get_directory_property get_filename_component get_property if include include_guard list macro mark_as_advanced math message option return separate_arguments set_directory_properties set_property set site_name string unset variable_watch while add_compile_definitions add_compile_options add_custom_command add_custom_target add_definitions add_dependencies add_executable add_library add_link_options add_subdirectory add_test aux_source_directory build_command create_test_sourcelist define_property enable_language enable_testing export fltk_wrap_ui get_source_file_property get_target_property get_test_property include_directories include_external_msproject include_regular_expression install link_directories link_libraries load_cache project qt_wrap_cpp qt_wrap_ui remove_definitions set_source_files_properties set_target_properties set_tests_properties source_group target_compile_definitions target_compile_features target_compile_options target_include_directories target_link_directories target_link_libraries target_link_options target_sources try_compile try_run ctest_build ctest_configure ctest_coverage ctest_empty_binary_directory ctest_memcheck ctest_read_custom_files ctest_run_script ctest_sleep ctest_start ctest_submit ctest_test ctest_update ctest_upload build_name exec_program export_library_dependencies install_files install_programs install_targets load_command make_directory output_required_files remove subdir_depends subdirs use_mangled_mesa utility_source variable_requires write_file qt5_use_modules qt5_use_package qt5_wrap_cpp on off true false and or not command policy target test exists is_newer_than is_directory is_symlink is_absolute matches less greater equal less_equal greater_equal strless strgreater strequal strless_equal strgreater_equal version_less version_greater version_equal version_less_equal version_greater_equal in_list defined"
-},contains:[{className:"variable",begin:/\$\{/,end:/\}/
-},e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE]})})());
-hljs.registerLanguage("coffeescript",(()=>{"use strict"
-;const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;return r=>{const t={
-keyword:e.concat(["then","unless","until","loop","by","when","and","or","is","isnt","not"]).filter((i=["var","const","let","function","static"],
-e=>!i.includes(e))),literal:n.concat(["yes","no","on","off"]),
-built_in:a.concat(["npm","print"])};var i;const s="[A-Za-z$_][0-9A-Za-z$_]*",o={
-className:"subst",begin:/#\{/,end:/\}/,keywords:t
-},c=[r.BINARY_NUMBER_MODE,r.inherit(r.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",
-relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/,
-contains:[r.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[r.BACKSLASH_ESCAPE]
-},{begin:/"""/,end:/"""/,contains:[r.BACKSLASH_ESCAPE,o]},{begin:/"/,end:/"/,
-contains:[r.BACKSLASH_ESCAPE,o]}]},{className:"regexp",variants:[{begin:"///",
-end:"///",contains:[o,r.HASH_COMMENT_MODE]},{begin:"//[gim]{0,3}(?=\\W)",
-relevance:0},{begin:/\/(?![ *]).*?(?![\\]).\/[gim]{0,3}(?=\W)/}]},{begin:"@"+s
-},{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{
-begin:"```",end:"```"},{begin:"`",end:"`"}]}];o.contains=c
-;const l=r.inherit(r.TITLE_MODE,{begin:s}),d="(\\(.*\\)\\s*)?\\B[-=]>",g={
-className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,
-end:/\)/,keywords:t,contains:["self"].concat(c)}]};return{name:"CoffeeScript",
-aliases:["coffee","cson","iced"],keywords:t,illegal:/\/\*/,
-contains:c.concat([r.COMMENT("###","###"),r.HASH_COMMENT_MODE,{
-className:"function",begin:"^\\s*"+s+"\\s*=\\s*"+d,end:"[-=]>",returnBegin:!0,
-contains:[l,g]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function",
-begin:d,end:"[-=]>",returnBegin:!0,contains:[g]}]},{className:"class",
-beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{
-beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[l]},l]
-},{begin:s+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}}})());
-hljs.registerLanguage("coq",(()=>{"use strict";return e=>({name:"Coq",keywords:{
-keyword:"_|0 as at cofix else end exists exists2 fix for forall fun if IF in let match mod Prop return Set then Type using where with Abort About Add Admit Admitted All Arguments Assumptions Axiom Back BackTo Backtrack Bind Blacklist Canonical Cd Check Class Classes Close Coercion Coercions CoFixpoint CoInductive Collection Combined Compute Conjecture Conjectures Constant constr Constraint Constructors Context Corollary CreateHintDb Cut Declare Defined Definition Delimit Dependencies Dependent Derive Drop eauto End Equality Eval Example Existential Existentials Existing Export exporting Extern Extract Extraction Fact Field Fields File Fixpoint Focus for From Function Functional Generalizable Global Goal Grab Grammar Graph Guarded Heap Hint HintDb Hints Hypotheses Hypothesis ident Identity If Immediate Implicit Import Include Inductive Infix Info Initial Inline Inspect Instance Instances Intro Intros Inversion Inversion_clear Language Left Lemma Let Libraries Library Load LoadPath Local Locate Ltac ML Mode Module Modules Monomorphic Morphism Next NoInline Notation Obligation Obligations Opaque Open Optimize Options Parameter Parameters Parametric Path Paths pattern Polymorphic Preterm Print Printing Program Projections Proof Proposition Pwd Qed Quit Rec Record Recursive Redirect Relation Remark Remove Require Reserved Reset Resolve Restart Rewrite Right Ring Rings Save Scheme Scope Scopes Script Search SearchAbout SearchHead SearchPattern SearchRewrite Section Separate Set Setoid Show Solve Sorted Step Strategies Strategy Structure SubClass Table Tables Tactic Term Test Theorem Time Timeout Transparent Type Typeclasses Types Undelimit Undo Unfocus Unfocused Unfold Universe Universes Unset Unshelve using Variable Variables Variant Verbose Visibility where with",
-built_in:"abstract absurd admit after apply as assert assumption at auto autorewrite autounfold before bottom btauto by case case_eq cbn cbv change classical_left classical_right clear clearbody cofix compare compute congruence constr_eq constructor contradict contradiction cut cutrewrite cycle decide decompose dependent destruct destruction dintuition discriminate discrR do double dtauto eapply eassumption eauto ecase econstructor edestruct ediscriminate eelim eexact eexists einduction einjection eleft elim elimtype enough equality erewrite eright esimplify_eq esplit evar exact exactly_once exfalso exists f_equal fail field field_simplify field_simplify_eq first firstorder fix fold fourier functional generalize generalizing gfail give_up has_evar hnf idtac in induction injection instantiate intro intro_pattern intros intuition inversion inversion_clear is_evar is_var lapply lazy left lia lra move native_compute nia nsatz omega once pattern pose progress proof psatz quote record red refine reflexivity remember rename repeat replace revert revgoals rewrite rewrite_strat right ring ring_simplify rtauto set setoid_reflexivity setoid_replace setoid_rewrite setoid_symmetry setoid_transitivity shelve shelve_unifiable simpl simple simplify_eq solve specialize split split_Rabs split_Rmult stepl stepr subst sum swap symmetry tactic tauto time timeout top transitivity trivial try tryif unfold unify until using vm_compute with"
-},contains:[e.QUOTE_STRING_MODE,e.COMMENT("\\(\\*","\\*\\)"),e.C_NUMBER_MODE,{
-className:"type",excludeBegin:!0,begin:"\\|\\s*",end:"\\w+"},{begin:/[-=]>/}]})
-})());
-hljs.registerLanguage("cos",(()=>{"use strict";return e=>({
-name:"Cach\xe9 Object Script",case_insensitive:!0,aliases:["cls"],
-keywords:"property parameter class classmethod clientmethod extends as break catch close continue do d|0 else elseif for goto halt hang h|0 if job j|0 kill k|0 lock l|0 merge new open quit q|0 read r|0 return set s|0 tcommit throw trollback try tstart use view while write w|0 xecute x|0 zkill znspace zn ztrap zwrite zw zzdump zzwrite print zbreak zinsert zload zprint zremove zsave zzprint mv mvcall mvcrt mvdim mvprint zquit zsync ascii",
-contains:[{className:"number",begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)",relevance:0},{
-className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',
-relevance:0}]}]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"comment",begin:/;/,end:"$",relevance:0},{className:"built_in",
-begin:/(?:\$\$?|\.\.)\^?[a-zA-Z]+/},{className:"built_in",
-begin:/\$\$\$[a-zA-Z]+/},{className:"built_in",begin:/%[a-z]+(?:\.[a-z]+)*/},{
-className:"symbol",begin:/\^%?[a-zA-Z][\w]*/},{className:"keyword",
-begin:/##class|##super|#define|#dim/},{begin:/&sql\(/,end:/\)/,excludeBegin:!0,
-excludeEnd:!0,subLanguage:"sql"},{begin:/&(js|jscript|javascript)</,end:/>/,
-excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"},{begin:/&html<\s*</,
-end:/>\s*>/,subLanguage:"xml"}]})})());
-hljs.registerLanguage("cpp",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
-const r=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),a="[a-zA-Z_]\\w*::",i="(decltype\\(auto\\)|"+e(a)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[n.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},r,n.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(a)+n.IDENT_RE,relevance:0
-},u=e(a)+n.IDENT_RE+"\\s*\\(",m={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
-keywords:m,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
-t("(?=",_,")")))};var _;const g=[p,l,s,r,n.C_BLOCK_COMMENT_MODE,o,c],b={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:g.concat([{
-begin:/\(/,end:/\)/,keywords:m,contains:g.concat(["self"]),relevance:0}]),
-relevance:0},f={className:"function",begin:"("+i+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:m,relevance:0,contains:[r,n.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/,
-end:/\)/,keywords:m,relevance:0,contains:["self",r,n.C_BLOCK_COMMENT_MODE,c,o,s]
-}]},s,r,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(b,f,p,g,[l,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:m,contains:["self",s]},{begin:n.IDENT_RE+"::",keywords:m},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
-preprocessor:l,strings:c,keywords:m}}}})());
-hljs.registerLanguage("crmsh",(()=>{"use strict";return e=>{
-const t="group clone ms master location colocation order fencing_topology rsc_ticket acl_target acl_group user role tag xml"
-;return{name:"crmsh",aliases:["crm","pcmk"],case_insensitive:!0,keywords:{
-keyword:"params meta operations op rule attributes utilization read write deny defined not_defined in_range date spec in ref reference attribute type xpath version and or lt gt tag lte gte eq ne \\ number string",
-literal:"Master Started Slave Stopped start promote demote stop monitor true false"
-},contains:[e.HASH_COMMENT_MODE,{beginKeywords:"node",starts:{
-end:"\\s*([\\w_-]+:)?",starts:{className:"title",end:"\\s*[\\$\\w_][\\w_-]*"}}
-},{beginKeywords:"primitive rsc_template",starts:{className:"title",
-end:"\\s*[\\$\\w_][\\w_-]*",starts:{end:"\\s*@?[\\w_][\\w_\\.:-]*"}}},{
-begin:"\\b("+t.split(" ").join("|")+")\\s+",keywords:t,starts:{
-className:"title",end:"[\\$\\w_][\\w_-]*"}},{
-beginKeywords:"property rsc_defaults op_defaults",starts:{className:"title",
-end:"\\s*([\\w_-]+:)?"}},e.QUOTE_STRING_MODE,{className:"meta",
-begin:"(ocf|systemd|service|lsb):[\\w_:-]+",relevance:0},{className:"number",
-begin:"\\b\\d+(\\.\\d+)?(ms|s|h|m)?",relevance:0},{className:"literal",
-begin:"[-]?(infinity|inf)",relevance:0},{className:"attr",
-begin:/([A-Za-z$_#][\w_-]+)=/,relevance:0},{className:"tag",begin:"</?",
-end:"/?>",relevance:0}]}}})());
-hljs.registerLanguage("crystal",(()=>{"use strict";return e=>{
-const n="(_?[ui](8|16|32|64|128))?",i="[a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|[=!]~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~|]|//|//=|&[-+*]=?|&\\*\\*|\\[\\][=?]?",s="[A-Za-z_]\\w*(::\\w+)*(\\?|!)?",a={
-$pattern:"[a-zA-Z_]\\w*[!?=]?",
-keyword:"abstract alias annotation as as? asm begin break case class def do else elsif end ensure enum extend for fun if include instance_sizeof is_a? lib macro module next nil? of out pointerof private protected rescue responds_to? return require select self sizeof struct super then type typeof union uninitialized unless until verbatim when while with yield __DIR__ __END_LINE__ __FILE__ __LINE__",
-literal:"false nil true"},t={className:"subst",begin:/#\{/,end:/\}/,keywords:a
-},c={className:"template-variable",variants:[{begin:"\\{\\{",end:"\\}\\}"},{
-begin:"\\{%",end:"%\\}"}],keywords:a};function r(e,n){const i=[{begin:e,end:n}]
-;return i[0].contains=i,i}const l={className:"string",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
-},{begin:/`/,end:/`/},{begin:"%[Qwi]?\\(",end:"\\)",contains:r("\\(","\\)")},{
-begin:"%[Qwi]?\\[",end:"\\]",contains:r("\\[","\\]")},{begin:"%[Qwi]?\\{",
-end:/\}/,contains:r(/\{/,/\}/)},{begin:"%[Qwi]?<",end:">",contains:r("<",">")},{
-begin:"%[Qwi]?\\|",end:"\\|"},{begin:/<<-\w+$/,end:/^\s*\w+$/}],relevance:0},b={
-className:"string",variants:[{begin:"%q\\(",end:"\\)",contains:r("\\(","\\)")},{
-begin:"%q\\[",end:"\\]",contains:r("\\[","\\]")},{begin:"%q\\{",end:/\}/,
-contains:r(/\{/,/\}/)},{begin:"%q<",end:">",contains:r("<",">")},{begin:"%q\\|",
-end:"\\|"},{begin:/<<-'\w+'$/,end:/^\s*\w+$/}],relevance:0},o={
-begin:"(?!%\\})("+e.RE_STARTERS_RE+"|\\n|\\b(case|if|select|unless|until|when|while)\\b)\\s*",
-keywords:"case if select unless until when while",contains:[{className:"regexp",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:"//[a-z]*",relevance:0},{
-begin:"/(?!\\/)",end:"/[a-z]*"}]}],relevance:0},g=[c,l,b,{className:"regexp",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:"%r\\(",end:"\\)",
-contains:r("\\(","\\)")},{begin:"%r\\[",end:"\\]",contains:r("\\[","\\]")},{
-begin:"%r\\{",end:/\}/,contains:r(/\{/,/\}/)},{begin:"%r<",end:">",
-contains:r("<",">")},{begin:"%r\\|",end:"\\|"}],relevance:0},o,{
-className:"meta",begin:"@\\[",end:"\\]",
-contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"})]
-},e.HASH_COMMENT_MODE,{className:"class",beginKeywords:"class module struct",
-end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{
-begin:s}),{begin:"<"}]},{className:"class",beginKeywords:"lib enum union",
-end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{
-begin:s})]},{beginKeywords:"annotation",end:"$|;",illegal:/=/,
-contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{begin:s})],relevance:2},{
-className:"function",beginKeywords:"def",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:i,endsParent:!0})]},{
-className:"function",beginKeywords:"fun macro",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:i,endsParent:!0})],relevance:2},{
-className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{
-className:"symbol",begin:":",contains:[l,{begin:i}],relevance:0},{
-className:"number",variants:[{begin:"\\b0b([01_]+)"+n},{begin:"\\b0o([0-7_]+)"+n
-},{begin:"\\b0x([A-Fa-f0-9_]+)"+n},{
-begin:"\\b([1-9][0-9_]*[0-9]|[0-9])(\\.[0-9][0-9_]*)?([eE]_?[-+]?[0-9_]*)?(_?f(32|64))?(?!_)"
-},{begin:"\\b([1-9][0-9_]*|0)"+n}],relevance:0}]
-;return t.contains=g,c.contains=g.slice(1),{name:"Crystal",aliases:["cr"],
-keywords:a,contains:g}}})());
-hljs.registerLanguage("csharp",(()=>{"use strict";return e=>{const n={
-keyword:["abstract","as","base","break","case","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]),
-built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"],
-literal:["default","false","null","true"]},a=e.inherit(e.TITLE_MODE,{
-begin:"[a-zA-Z](\\.?\\w)*"}),i={className:"number",variants:[{
-begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]
-},t=e.inherit(s,{illegal:/\n/}),r={className:"subst",begin:/\{/,end:/\}/,
-keywords:n},l=e.inherit(r,{illegal:/\n/}),c={className:"string",begin:/\$"/,
-end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/
-},e.BACKSLASH_ESCAPE,l]},o={className:"string",begin:/\$@"/,end:'"',contains:[{
-begin:/\{\{/},{begin:/\}\}/},{begin:'""'},r]},d=e.inherit(o,{illegal:/\n/,
-contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},l]})
-;r.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_BLOCK_COMMENT_MODE],
-l.contains=[d,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.inherit(e.C_BLOCK_COMMENT_MODE,{
-illegal:/\n/})];const g={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},E={begin:"<",end:">",contains:[{beginKeywords:"in out"},a]
-},_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={
-begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],
-keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0,
-contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{
-begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}]
-}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",
-end:"$",keywords:{
-"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"
-}},g,i,{beginKeywords:"class interface",relevance:0,end:/[{;=]/,
-illegal:/[^\s:,]/,contains:[{beginKeywords:"where class"
-},a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",
-relevance:0,end:/[{;=]/,illegal:/[^\s:]/,
-contains:[a,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{
-beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/,
-contains:[a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta",
-begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{
-className:"meta-string",begin:/"/,end:/"/}]},{
-beginKeywords:"new return throw await else",relevance:0},{className:"function",
-begin:"("+_+"\\s+)+"+e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0,
-end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{
-beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial",
-relevance:0},{begin:e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0,
-contains:[e.TITLE_MODE,E],relevance:0},{className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0,
-contains:[g,i,e.C_BLOCK_COMMENT_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}})());
-hljs.registerLanguage("csp",(()=>{"use strict";return e=>({name:"CSP",
-case_insensitive:!1,keywords:{$pattern:"[a-zA-Z][a-zA-Z0-9_-]*",
-keyword:"base-uri child-src connect-src default-src font-src form-action frame-ancestors frame-src img-src media-src object-src plugin-types report-uri sandbox script-src style-src"
-},contains:[{className:"string",begin:"'",end:"'"},{className:"attribute",
-begin:"^Content",end:":",excludeEnd:!0}]})})());
-hljs.registerLanguage("css",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
-;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(n),l=[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE];return{name:"CSS",
-case_insensitive:!0,illegal:/[=|'\$]/,keywords:{keyframePosition:"from to"},
-classNameAliases:{keyframePosition:"selector-tag"},
-contains:[n.C_BLOCK_COMMENT_MODE,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/
-},n.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0
-},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0
-},a.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{
-begin:":("+i.join("|")+")"},{begin:"::("+o.join("|")+")"}]},{
-className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{begin:":",end:"[;}]",
-contains:[a.HEXCOLOR,a.IMPORTANT,n.CSS_NUMBER_MODE,...l,{
-begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri"
-},contains:[{className:"string",begin:/[^)]/,endsWithParent:!0,excludeEnd:!0}]
-},{className:"built_in",begin:/[\w-]+(?=\()/}]},{
-begin:(s=/@/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",s,")")),
-end:"[{;]",relevance:0,illegal:/:/,contains:[{className:"keyword",
-begin:/@-?\w[\w]*(-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,
-relevance:0,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",
-attribute:t.join(" ")},contains:[{begin:/[a-z-]+(?=:)/,className:"attribute"
-},...l,n.CSS_NUMBER_MODE]}]},{className:"selector-tag",
-begin:"\\b("+e.join("|")+")\\b"}]};var s}})());
-hljs.registerLanguage("d",(()=>{"use strict";return e=>{const a={
-$pattern:e.UNDERSCORE_IDENT_RE,
-keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
-built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",
-literal:"false null true"
-},d="((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))",n="\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",t={
-className:"number",begin:"\\b"+d+"(L|u|U|Lu|LU|uL|UL)?",relevance:0},_={
-className:"number",
-begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|"+d+"(i|[fF]i|Li))",
-relevance:0},r={className:"string",begin:"'("+n+"|.)",end:"'",illegal:"."},i={
-className:"string",begin:'"',contains:[{begin:n,relevance:0}],end:'"[cwd]?'
-},s=e.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{
-name:"D",keywords:a,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{
-className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},i,{
-className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",
-begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},_,t,r,{
-className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",
-begin:"#(line)",end:"$",relevance:5},{className:"keyword",
-begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}}})());
-hljs.registerLanguage("markdown",(()=>{"use strict";function n(...n){
-return n.map((n=>{return(e=n)?"string"==typeof e?e:e.source:null;var e
-})).join("")}return e=>{const a={begin:/<\/?[A-Za-z_]/,end:">",
-subLanguage:"xml",relevance:0},i={variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0
-},{begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/,
-relevance:2},{begin:n(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/),
-relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{
-begin:/\[.+?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{
-className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0,
-returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)",
-excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[",
-end:"\\]",excludeBegin:!0,excludeEnd:!0}]},s={className:"strong",contains:[],
-variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},c={
-className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{
-begin:/_(?!_)/,end:/_/,relevance:0}]};s.contains.push(c),c.contains.push(s)
-;let t=[a,i]
-;return s.contains=s.contains.concat(t),c.contains=c.contains.concat(t),
-t=t.concat(s,c),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{
-className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:t},{
-begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",
-contains:t}]}]},a,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",
-end:"\\s+",excludeEnd:!0},s,c,{className:"quote",begin:"^>\\s+",contains:t,
-end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{
-begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{
-begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",
-contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{
-begin:"^[-\\*]{3,}",end:"$"},i,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{
-className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{
-className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}}})());
-hljs.registerLanguage("dart",(()=>{"use strict";return e=>{const n={
-className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"}]},a={className:"subst",
-variants:[{begin:/\$\{/,end:/\}/}],keywords:"true false null this is new super"
-},t={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',
-end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"
-},{begin:"'''",end:"'''",contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:'"""',
-end:'"""',contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:"'",end:"'",illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:'"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE,n,a]}]};a.contains=[e.C_NUMBER_MODE,t]
-;const i=["Comparable","DateTime","Duration","Function","Iterable","Iterator","List","Map","Match","Object","Pattern","RegExp","Set","Stopwatch","String","StringBuffer","StringSink","Symbol","Type","Uri","bool","double","int","num","Element","ElementList"],r=i.map((e=>e+"?"))
-;return{name:"Dart",keywords:{
-keyword:"abstract as assert async await break case catch class const continue covariant default deferred do dynamic else enum export extends extension external factory false final finally for Function get hide if implements import in inferface is late library mixin new null on operator part required rethrow return set show static super switch sync this throw true try typedef var void while with yield",
-built_in:i.concat(r).concat(["Never","Null","dynamic","print","document","querySelector","querySelectorAll","window"]),
-$pattern:/[A-Za-z][A-Za-z0-9_]*\??/},
-contains:[t,e.COMMENT(/\/\*\*(?!\/)/,/\*\//,{subLanguage:"markdown",relevance:0
-}),e.COMMENT(/\/{3,} ?/,/$/,{contains:[{subLanguage:"markdown",begin:".",
-end:"$",relevance:0}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"class",beginKeywords:"class interface",end:/\{/,excludeEnd:!0,
-contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]
-},e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}}})());
-hljs.registerLanguage("delphi",(()=>{"use strict";return e=>{
-const r="exports register file shl array record property for mod while set ally label uses raise not stored class safecall var interface or private static exit index inherited to else stdcall override shr asm far resourcestring finalization packed virtual out and protected library do xorwrite goto near function end div overload object unit begin string on inline repeat until destructor write message program with read initialization except default nil if case cdecl in downto threadvar of try pascal const external constructor type public then implementation finally published procedure absolute reintroduce operator as is abstract alias assembler bitpacked break continue cppdecl cvar enumerator experimental platform deprecated unimplemented dynamic export far16 forward generic helper implements interrupt iochecks local name nodefault noreturn nostackframe oldfpccall otherwise saveregisters softfloat specialize strict unaligned varargs ",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{
-relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],t={className:"meta",
-variants:[{begin:/\{\$/,end:/\}/},{begin:/\(\*\$/,end:/\*\)/}]},n={
-className:"string",begin:/'/,end:/'/,contains:[{begin:/''/}]},s={
-className:"string",begin:/(#\d+)+/},i={begin:e.IDENT_RE+"\\s*=\\s*class\\s*\\(",
-returnBegin:!0,contains:[e.TITLE_MODE]},c={className:"function",
-beginKeywords:"function constructor destructor procedure",end:/[:;]/,
-keywords:"function constructor|10 destructor|10 procedure|10",
-contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:r,
-contains:[n,s,t].concat(a)},t].concat(a)};return{name:"Delphi",
-aliases:["dpr","dfm","pas","pascal","freepascal","lazarus","lpr","lfm"],
-case_insensitive:!0,keywords:r,illegal:/"|\$[G-Zg-z]|\/\*|<\/|\|/,
-contains:[n,s,e.NUMBER_MODE,{className:"number",relevance:0,variants:[{
-begin:"\\$[0-9A-Fa-f]+"},{begin:"&[0-7]+"},{begin:"%[01]+"}]},i,c,t].concat(a)}}
-})());
-hljs.registerLanguage("diff",(()=>{"use strict";return e=>({name:"Diff",
-aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{
-begin:/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{
-begin:/^--- +\d+,\d+ +----$/}]},{className:"comment",variants:[{begin:/Index: /,
-end:/$/},{begin:/^index/,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^-{3}/,end:/$/
-},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/^\*{15}$/},{
-begin:/^diff --git/,end:/$/}]},{className:"addition",begin:/^\+/,end:/$/},{
-className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/,
-end:/$/}]})})());
-hljs.registerLanguage("django",(()=>{"use strict";return e=>{const t={
-begin:/\|[A-Za-z]+:?/,keywords:{
-name:"truncatewords removetags linebreaksbr yesno get_digit timesince random striptags filesizeformat escape linebreaks length_is ljust rjust cut urlize fix_ampersands title floatformat capfirst pprint divisibleby add make_list unordered_list urlencode timeuntil urlizetrunc wordcount stringformat linenumbers slice date dictsort dictsortreversed default_if_none pluralize lower join center default truncatewords_html upper length phone2numeric wordwrap time addslashes slugify first escapejs force_escape iriencode last safe safeseq truncatechars localize unlocalize localtime utc timezone"
-},contains:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE]};return{name:"Django",
-aliases:["jinja"],case_insensitive:!0,subLanguage:"xml",
-contains:[e.COMMENT(/\{%\s*comment\s*%\}/,/\{%\s*endcomment\s*%\}/),e.COMMENT(/\{#/,/#\}/),{
-className:"template-tag",begin:/\{%/,end:/%\}/,contains:[{className:"name",
-begin:/\w+/,keywords:{
-name:"comment endcomment load templatetag ifchanged endifchanged if endif firstof for endfor ifnotequal endifnotequal widthratio extends include spaceless endspaceless regroup ifequal endifequal ssi now with cycle url filter endfilter debug block endblock else autoescape endautoescape csrf_token empty elif endwith static trans blocktrans endblocktrans get_static_prefix get_media_prefix plural get_current_language language get_available_languages get_current_language_bidi get_language_info get_language_info_list localize endlocalize localtime endlocaltime timezone endtimezone get_current_timezone verbatim"
-},starts:{endsWithParent:!0,keywords:"in by as",contains:[t],relevance:0}}]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[t]}]}}})());
-hljs.registerLanguage("dns",(()=>{"use strict";return d=>({name:"DNS Zone",
-aliases:["bind","zone"],keywords:{
-keyword:"IN A AAAA AFSDB APL CAA CDNSKEY CDS CERT CNAME DHCID DLV DNAME DNSKEY DS HIP IPSECKEY KEY KX LOC MX NAPTR NS NSEC NSEC3 NSEC3PARAM PTR RRSIG RP SIG SOA SRV SSHFP TA TKEY TLSA TSIG TXT"
-},contains:[d.COMMENT(";","$",{relevance:0}),{className:"meta",
-begin:/^\$(TTL|GENERATE|INCLUDE|ORIGIN)\b/},{className:"number",
-begin:"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))\\b"
-},{className:"number",
-begin:"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\b"
-},d.inherit(d.NUMBER_MODE,{begin:/\b\d+[dhwm]?/})]})})());
-hljs.registerLanguage("dockerfile",(()=>{"use strict";return e=>({
-name:"Dockerfile",aliases:["docker"],case_insensitive:!0,
-keywords:"from maintainer expose env arg user onbuild stopsignal",
-contains:[e.HASH_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{
-beginKeywords:"run cmd entrypoint volume add copy workdir label healthcheck shell",
-starts:{end:/[^\\]$/,subLanguage:"bash"}}],illegal:"</"})})());
-hljs.registerLanguage("dos",(()=>{"use strict";return e=>{
-const t=e.COMMENT(/^\s*@?rem\b/,/$/,{relevance:10});return{
-name:"Batch file (DOS)",aliases:["bat","cmd"],case_insensitive:!0,
-illegal:/\/\*/,keywords:{
-keyword:"if else goto for in do call exit not exist errorlevel defined equ neq lss leq gtr geq",
-built_in:"prn nul lpt3 lpt2 lpt1 con com4 com3 com2 com1 aux shift cd dir echo setlocal endlocal set pause copy append assoc at attrib break cacls cd chcp chdir chkdsk chkntfs cls cmd color comp compact convert date dir diskcomp diskcopy doskey erase fs find findstr format ftype graftabl help keyb label md mkdir mode more move path pause print popd pushd promt rd recover rem rename replace restore rmdir shift sort start subst time title tree type ver verify vol ping net ipconfig taskkill xcopy ren del"
-},contains:[{className:"variable",begin:/%%[^ ]|%[^ ]+?%|![^ ]+?!/},{
-className:"function",begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)",
-end:"goto:eof",contains:[e.inherit(e.TITLE_MODE,{
-begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),t]},{
-className:"number",begin:"\\b\\d+",relevance:0},t]}}})());
-hljs.registerLanguage("dsconfig",(()=>{"use strict";return e=>({
-keywords:"dsconfig",contains:[{className:"keyword",begin:"^dsconfig",end:/\s/,
-excludeEnd:!0,relevance:10},{className:"built_in",
-begin:/(list|create|get|set|delete)-(\w+)/,end:/\s/,excludeEnd:!0,
-illegal:"!@#$%^&*()",relevance:10},{className:"built_in",begin:/--(\w+)/,
-end:/\s/,excludeEnd:!0},{className:"string",begin:/"/,end:/"/},{
-className:"string",begin:/'/,end:/'/},{className:"string",begin:/[\w\-?]+:\w+/,
-end:/\W/,relevance:0},{className:"string",begin:/\w+(\-\w+)*/,end:/(?=\W)/,
-relevance:0},e.HASH_COMMENT_MODE]})})());
-hljs.registerLanguage("dts",(()=>{"use strict";return e=>{const n={
-className:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{
-begin:'((u8?|U)|L)?"'}),{begin:'(u8?|U)?R"',end:'"',
-contains:[e.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},a={
-className:"number",variants:[{
-begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)(u|U|l|L|ul|UL|f|F)"},{begin:e.C_NUMBER_RE}],
-relevance:0},s={className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":"if else elif endif define undef ifdef ifndef"},contains:[{
-begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",keywords:{
-"meta-keyword":"include"},contains:[e.inherit(n,{className:"meta-string"}),{
-className:"meta-string",begin:"<",end:">",illegal:"\\n"}]
-},n,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},i={className:"variable",
-begin:/&[a-z\d_]*\b/},d={className:"meta-keyword",begin:"/[a-z][a-z\\d-]*/"},l={
-className:"symbol",begin:"^\\s*[a-zA-Z_][a-zA-Z\\d_]*:"},r={className:"params",
-begin:"<",end:">",contains:[a,i]},_={className:"class",
-begin:/[a-zA-Z_][a-zA-Z\d_@]*\s\{/,end:/[{;=]/,returnBegin:!0,excludeEnd:!0}
-;return{name:"Device Tree",keywords:"",contains:[{className:"class",
-begin:"/\\s*\\{",end:/\};/,relevance:10,
-contains:[i,d,l,_,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,n]
-},i,d,l,_,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,n,s,{
-begin:e.IDENT_RE+"::",keywords:""}]}}})());
-hljs.registerLanguage("dust",(()=>{"use strict";return e=>({name:"Dust",
-aliases:["dst"],case_insensitive:!0,subLanguage:"xml",contains:[{
-className:"template-tag",begin:/\{[#\/]/,end:/\}/,illegal:/;/,contains:[{
-className:"name",begin:/[a-zA-Z\.-]+/,starts:{endsWithParent:!0,relevance:0,
-contains:[e.QUOTE_STRING_MODE]}}]},{className:"template-variable",begin:/\{/,
-end:/\}/,illegal:/;/,keywords:"if eq ne lt lte gt gte select default math sep"}]
-})})());
-hljs.registerLanguage("ebnf",(()=>{"use strict";return e=>{
-const a=e.COMMENT(/\(\*/,/\*\)/);return{name:"Extended Backus-Naur Form",
-illegal:/\S/,contains:[a,{className:"attribute",
-begin:/^[ ]*[a-zA-Z]+([\s_-]+[a-zA-Z]+)*/},{begin:/=/,end:/[.;]/,contains:[a,{
-className:"meta",begin:/\?.*\?/},{className:"string",
-variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"`",end:"`"}]}]}]}}
-})());
-hljs.registerLanguage("elixir",(()=>{"use strict";return e=>{
-const n="[a-zA-Z_][a-zA-Z0-9_.]*(!|\\?)?",i={$pattern:n,
-keyword:"and false then defined module in return redo retry end for true self when next until do begin unless nil break not case cond alias while ensure or include use alias fn quote require import with|0"
-},a={className:"subst",begin:/#\{/,end:/\}/,keywords:i},s={className:"number",
-begin:"(\\b0o[0-7_]+)|(\\b0b[01_]+)|(\\b0x[0-9a-fA-F_]+)|(-?\\b[1-9][0-9_]*(\\.[0-9_]+([eE][-+]?[0-9]+)?)?)",
-relevance:0},b={className:"string",begin:"~[a-z](?=[/|([{<\"'])",contains:[{
-endsParent:!0,contains:[{contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:/"/,
-end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{begin:/\|/,end:/\|/},{
-begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,end:/\}/},{begin:/</,
-end:/>/}]}]}]},d={className:"string",contains:[e.BACKSLASH_ESCAPE,a],variants:[{
-begin:/"""/,end:/"""/},{begin:/'''/,end:/'''/},{begin:/~S"""/,end:/"""/,
-contains:[]},{begin:/~S"/,end:/"/,contains:[]},{begin:/~S'''/,end:/'''/,
-contains:[]},{begin:/~S'/,end:/'/,contains:[]},{begin:/'/,end:/'/},{begin:/"/,
-end:/"/}]},r={className:"function",beginKeywords:"def defp defmacro",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:n,endsParent:!0})]},g=e.inherit(r,{
-className:"class",beginKeywords:"defimpl defmodule defprotocol defrecord",
-end:/\bdo\b|$|;/}),t=[d,{className:"string",begin:"~[A-Z](?=[/|([{<\"'])",
-contains:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{
-begin:/\|/,end:/\|/},{begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,
-end:/\}/},{begin:/</,end:/>/}]},b,e.HASH_COMMENT_MODE,g,r,{begin:"::"},{
-className:"symbol",begin:":(?![\\s:])",contains:[d,{
-begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"
-}],relevance:0},{className:"symbol",begin:n+":(?!:)",relevance:0},s,{
-className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))"},{begin:"->"},{
-begin:"("+e.RE_STARTERS_RE+")\\s*",contains:[e.HASH_COMMENT_MODE,{
-begin:/\/: (?=\d+\s*[,\]])/,relevance:0,contains:[s]},{className:"regexp",
-illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:"/",end:"/[a-z]*"
-},{begin:"%r\\[",end:"\\][a-z]*"}]}],relevance:0}];return a.contains=t,{
-name:"Elixir",keywords:i,contains:t}}})());
-hljs.registerLanguage("elm",(()=>{"use strict";return e=>{const n={
-variants:[e.COMMENT("--","$"),e.COMMENT(/\{-/,/-\}/,{contains:["self"]})]},i={
-className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},s={begin:"\\(",end:"\\)",
-illegal:'"',contains:[{className:"type",
-begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},n]};return{name:"Elm",
-keywords:"let in if then else case of where module import exposing type alias as infix infixl infixr port effect command subscription",
-contains:[{beginKeywords:"port effect module",end:"exposing",
-keywords:"port effect module where command subscription exposing",
-contains:[s,n],illegal:"\\W\\.|;"},{begin:"import",end:"$",
-keywords:"import as exposing",contains:[s,n],illegal:"\\W\\.|;"},{begin:"type",
-end:"$",keywords:"type alias",contains:[i,s,{begin:/\{/,end:/\}/,
-contains:s.contains},n]},{beginKeywords:"infix infixl infixr",end:"$",
-contains:[e.C_NUMBER_MODE,n]},{begin:"port",end:"$",keywords:"port",contains:[n]
-},{className:"string",begin:"'\\\\?.",end:"'",illegal:"."
-},e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,i,e.inherit(e.TITLE_MODE,{
-begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}],illegal:/;/}}})());
-hljs.registerLanguage("ruby",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{
-const a="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",i={
-keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor __FILE__",
-built_in:"proc lambda",literal:"true false nil"},s={className:"doctag",
-begin:"@[A-Za-z]+"},r={begin:"#<",end:">"},b=[n.COMMENT("#","$",{contains:[s]
-}),n.COMMENT("^=begin","^=end",{contains:[s],relevance:10
-}),n.COMMENT("^__END__","\\n$")],c={className:"subst",begin:/#\{/,end:/\}/,
-keywords:i},t={className:"string",contains:[n.BACKSLASH_ESCAPE,c],variants:[{
-begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,
-end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{
-begin:/%[qQwWx]?</,end:/>/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,
-end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{
-begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{
-begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{
-begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{
-begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{
-begin:/<<[-~]?'?(\w+)\n(?:[^\n]*\n)*?\s*\1\b/,returnBegin:!0,contains:[{
-begin:/<<[-~]?'?/},n.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,
-contains:[n.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",d={className:"number",
-relevance:0,variants:[{
-begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{
-begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"
-},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{
-begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{
-begin:"\\b0(_?[0-7])+r?i?\\b"}]},l={className:"params",begin:"\\(",end:"\\)",
-endsParent:!0,keywords:i},o=[t,{className:"class",beginKeywords:"class module",
-end:"$|;",illegal:/=/,contains:[n.inherit(n.TITLE_MODE,{
-begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|!)?"}),{begin:"<\\s*",contains:[{
-begin:"("+n.IDENT_RE+"::)?"+n.IDENT_RE,relevance:0}]}].concat(b)},{
-className:"function",begin:e(/def\s+/,(_=a+"\\s*(\\(|;|$)",e("(?=",_,")"))),
-relevance:0,keywords:"def",end:"$|;",contains:[n.inherit(n.TITLE_MODE,{begin:a
-}),l].concat(b)},{begin:n.IDENT_RE+"::"},{className:"symbol",
-begin:n.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",
-begin:":(?!\\s)",contains:[t,{begin:a}],relevance:0},d,{className:"variable",
-begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{
-className:"params",begin:/\|/,end:/\|/,relevance:0,keywords:i},{
-begin:"("+n.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{
-className:"regexp",contains:[n.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{
-begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",
-end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]
-}].concat(r,b),relevance:0}].concat(r,b);var _;c.contains=o,l.contains=o
-;const E=[{begin:/^\s*=>/,starts:{end:"$",contains:o}},{className:"meta",
-begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])",
-starts:{end:"$",contains:o}}];return b.unshift(r),{name:"Ruby",
-aliases:["rb","gemspec","podspec","thor","irb"],keywords:i,illegal:/\/\*/,
-contains:[n.SHEBANG({binary:"ruby"})].concat(E).concat(b).concat(o)}}})());
-hljs.registerLanguage("erb",(()=>{"use strict";return e=>({name:"ERB",
-subLanguage:"xml",contains:[e.COMMENT("<%#","%>"),{begin:"<%[%=-]?",
-end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0}]})})());
-hljs.registerLanguage("erlang-repl",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>({name:"Erlang REPL",keywords:{
-built_in:"spawn spawn_link self",
-keyword:"after and andalso|10 band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse|10 query receive rem try when xor"
-},contains:[{className:"meta",begin:"^[0-9]+> ",relevance:10
-},n.COMMENT("%","$"),{className:"number",
-begin:"\\b(\\d+(_\\d+)*#[a-fA-F0-9]+(_[a-fA-F0-9]+)*|\\d+(_\\d+)*(\\.\\d+(_\\d+)*)?([eE][-+]?\\d+)?)",
-relevance:0},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{
-begin:e(/\?(::)?/,/([A-Z]\w*)/,/((::)[A-Z]\w*)*/)},{begin:"->"},{begin:"ok"},{
-begin:"!"},{
-begin:"(\\b[a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*)|(\\b[a-z'][a-zA-Z0-9_']*)",
-relevance:0},{begin:"[A-Z][a-zA-Z0-9_']*",relevance:0}]})})());
-hljs.registerLanguage("erlang",(()=>{"use strict";return e=>{
-const n="[a-z'][a-zA-Z0-9_']*",r="("+n+":"+n+"|"+n+")",a={
-keyword:"after and andalso|10 band begin bnot bor bsl bzr bxor case catch cond div end fun if let not of orelse|10 query receive rem try when xor",
-literal:"false true"},i=e.COMMENT("%","$"),s={className:"number",
-begin:"\\b(\\d+(_\\d+)*#[a-fA-F0-9]+(_[a-fA-F0-9]+)*|\\d+(_\\d+)*(\\.\\d+(_\\d+)*)?([eE][-+]?\\d+)?)",
-relevance:0},c={begin:"fun\\s+"+n+"/\\d+"},t={begin:r+"\\(",end:"\\)",
-returnBegin:!0,relevance:0,contains:[{begin:r,relevance:0},{begin:"\\(",
-end:"\\)",endsWithParent:!0,returnEnd:!0,relevance:0}]},d={begin:/\{/,end:/\}/,
-relevance:0},o={begin:"\\b_([A-Z][A-Za-z0-9_]*)?",relevance:0},l={
-begin:"[A-Z][a-zA-Z0-9_]*",relevance:0},b={begin:"#"+e.UNDERSCORE_IDENT_RE,
-relevance:0,returnBegin:!0,contains:[{begin:"#"+e.UNDERSCORE_IDENT_RE,
-relevance:0},{begin:/\{/,end:/\}/,relevance:0}]},g={
-beginKeywords:"fun receive if try case",end:"end",keywords:a}
-;g.contains=[i,c,e.inherit(e.APOS_STRING_MODE,{className:""
-}),g,t,e.QUOTE_STRING_MODE,s,d,o,l,b]
-;const E=[i,c,g,t,e.QUOTE_STRING_MODE,s,d,o,l,b]
-;t.contains[1].contains=E,d.contains=E,b.contains[1].contains=E;const u={
-className:"params",begin:"\\(",end:"\\)",contains:E};return{name:"Erlang",
-aliases:["erl"],keywords:a,illegal:"(</|\\*=|\\+=|-=|/\\*|\\*/|\\(\\*|\\*\\))",
-contains:[{className:"function",begin:"^"+n+"\\s*\\(",end:"->",returnBegin:!0,
-illegal:"\\(|#|//|/\\*|\\\\|:|;",contains:[u,e.inherit(e.TITLE_MODE,{begin:n})],
-starts:{end:";|\\.",keywords:a,contains:E}},i,{begin:"^-",end:"\\.",relevance:0,
-excludeEnd:!0,returnBegin:!0,keywords:{$pattern:"-"+e.IDENT_RE,
-keyword:["-module","-record","-undef","-export","-ifdef","-ifndef","-author","-copyright","-doc","-vsn","-import","-include","-include_lib","-compile","-define","-else","-endif","-file","-behaviour","-behavior","-spec"].map((e=>e+"|1.5")).join(" ")
-},contains:[u]},s,e.QUOTE_STRING_MODE,b,o,l,d,{begin:/\.$/}]}}})());
-hljs.registerLanguage("excel",(()=>{"use strict";return E=>({
-name:"Excel formulae",aliases:["xlsx","xls"],case_insensitive:!0,keywords:{
-$pattern:/[a-zA-Z][\w\.]*/,
-built_in:"ABS ACCRINT ACCRINTM ACOS ACOSH ACOT ACOTH AGGREGATE ADDRESS AMORDEGRC AMORLINC AND ARABIC AREAS ASC ASIN ASINH ATAN ATAN2 ATANH AVEDEV AVERAGE AVERAGEA AVERAGEIF AVERAGEIFS BAHTTEXT BASE BESSELI BESSELJ BESSELK BESSELY BETADIST BETA.DIST BETAINV BETA.INV BIN2DEC BIN2HEX BIN2OCT BINOMDIST BINOM.DIST BINOM.DIST.RANGE BINOM.INV BITAND BITLSHIFT BITOR BITRSHIFT BITXOR CALL CEILING CEILING.MATH CEILING.PRECISE CELL CHAR CHIDIST CHIINV CHITEST CHISQ.DIST CHISQ.DIST.RT CHISQ.INV CHISQ.INV.RT CHISQ.TEST CHOOSE CLEAN CODE COLUMN COLUMNS COMBIN COMBINA COMPLEX CONCAT CONCATENATE CONFIDENCE CONFIDENCE.NORM CONFIDENCE.T CONVERT CORREL COS COSH COT COTH COUNT COUNTA COUNTBLANK COUNTIF COUNTIFS COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD COVAR COVARIANCE.P COVARIANCE.S CRITBINOM CSC CSCH CUBEKPIMEMBER CUBEMEMBER CUBEMEMBERPROPERTY CUBERANKEDMEMBER CUBESET CUBESETCOUNT CUBEVALUE CUMIPMT CUMPRINC DATE DATEDIF DATEVALUE DAVERAGE DAY DAYS DAYS360 DB DBCS DCOUNT DCOUNTA DDB DEC2BIN DEC2HEX DEC2OCT DECIMAL DEGREES DELTA DEVSQ DGET DISC DMAX DMIN DOLLAR DOLLARDE DOLLARFR DPRODUCT DSTDEV DSTDEVP DSUM DURATION DVAR DVARP EDATE EFFECT ENCODEURL EOMONTH ERF ERF.PRECISE ERFC ERFC.PRECISE ERROR.TYPE EUROCONVERT EVEN EXACT EXP EXPON.DIST EXPONDIST FACT FACTDOUBLE FALSE|0 F.DIST FDIST F.DIST.RT FILTERXML FIND FINDB F.INV F.INV.RT FINV FISHER FISHERINV FIXED FLOOR FLOOR.MATH FLOOR.PRECISE FORECAST FORECAST.ETS FORECAST.ETS.CONFINT FORECAST.ETS.SEASONALITY FORECAST.ETS.STAT FORECAST.LINEAR FORMULATEXT FREQUENCY F.TEST FTEST FV FVSCHEDULE GAMMA GAMMA.DIST GAMMADIST GAMMA.INV GAMMAINV GAMMALN GAMMALN.PRECISE GAUSS GCD GEOMEAN GESTEP GETPIVOTDATA GROWTH HARMEAN HEX2BIN HEX2DEC HEX2OCT HLOOKUP HOUR HYPERLINK HYPGEOM.DIST HYPGEOMDIST IF IFERROR IFNA IFS IMABS IMAGINARY IMARGUMENT IMCONJUGATE IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMPRODUCT IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB IMSUM IMTAN INDEX INDIRECT INFO INT INTERCEPT INTRATE IPMT IRR ISBLANK ISERR ISERROR ISEVEN ISFORMULA ISLOGICAL ISNA ISNONTEXT ISNUMBER ISODD ISREF ISTEXT ISO.CEILING ISOWEEKNUM ISPMT JIS KURT LARGE LCM LEFT LEFTB LEN LENB LINEST LN LOG LOG10 LOGEST LOGINV LOGNORM.DIST LOGNORMDIST LOGNORM.INV LOOKUP LOWER MATCH MAX MAXA MAXIFS MDETERM MDURATION MEDIAN MID MIDBs MIN MINIFS MINA MINUTE MINVERSE MIRR MMULT MOD MODE MODE.MULT MODE.SNGL MONTH MROUND MULTINOMIAL MUNIT N NA NEGBINOM.DIST NEGBINOMDIST NETWORKDAYS NETWORKDAYS.INTL NOMINAL NORM.DIST NORMDIST NORMINV NORM.INV NORM.S.DIST NORMSDIST NORM.S.INV NORMSINV NOT NOW NPER NPV NUMBERVALUE OCT2BIN OCT2DEC OCT2HEX ODD ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD OFFSET OR PDURATION PEARSON PERCENTILE.EXC PERCENTILE.INC PERCENTILE PERCENTRANK.EXC PERCENTRANK.INC PERCENTRANK PERMUT PERMUTATIONA PHI PHONETIC PI PMT POISSON.DIST POISSON POWER PPMT PRICE PRICEDISC PRICEMAT PROB PRODUCT PROPER PV QUARTILE QUARTILE.EXC QUARTILE.INC QUOTIENT RADIANS RAND RANDBETWEEN RANK.AVG RANK.EQ RANK RATE RECEIVED REGISTER.ID REPLACE REPLACEB REPT RIGHT RIGHTB ROMAN ROUND ROUNDDOWN ROUNDUP ROW ROWS RRI RSQ RTD SEARCH SEARCHB SEC SECH SECOND SERIESSUM SHEET SHEETS SIGN SIN SINH SKEW SKEW.P SLN SLOPE SMALL SQL.REQUEST SQRT SQRTPI STANDARDIZE STDEV STDEV.P STDEV.S STDEVA STDEVP STDEVPA STEYX SUBSTITUTE SUBTOTAL SUM SUMIF SUMIFS SUMPRODUCT SUMSQ SUMX2MY2 SUMX2PY2 SUMXMY2 SWITCH SYD T TAN TANH TBILLEQ TBILLPRICE TBILLYIELD T.DIST T.DIST.2T T.DIST.RT TDIST TEXT TEXTJOIN TIME TIMEVALUE T.INV T.INV.2T TINV TODAY TRANSPOSE TREND TRIM TRIMMEAN TRUE|0 TRUNC T.TEST TTEST TYPE UNICHAR UNICODE UPPER VALUE VAR VAR.P VAR.S VARA VARP VARPA VDB VLOOKUP WEBSERVICE WEEKDAY WEEKNUM WEIBULL WEIBULL.DIST WORKDAY WORKDAY.INTL XIRR XNPV XOR YEAR YEARFRAC YIELD YIELDDISC YIELDMAT Z.TEST ZTEST"
-},contains:[{begin:/^=/,end:/[^=]/,returnEnd:!0,illegal:/=/,relevance:10},{
-className:"symbol",begin:/\b[A-Z]{1,2}\d+\b/,end:/[^\d]/,excludeEnd:!0,
-relevance:0},{className:"symbol",begin:/[A-Z]{0,2}\d*:[A-Z]{0,2}\d*/,relevance:0
-},E.BACKSLASH_ESCAPE,E.QUOTE_STRING_MODE,{className:"number",
-begin:E.NUMBER_RE+"(%)?",relevance:0},E.COMMENT(/\bN\(/,/\)/,{excludeBegin:!0,
-excludeEnd:!0,illegal:/\n/})]})})());
-hljs.registerLanguage("fix",(()=>{"use strict";return e=>({name:"FIX",
-contains:[{begin:/[^\u2401\u0001]+/,end:/[\u2401\u0001]/,excludeEnd:!0,
-returnBegin:!0,returnEnd:!1,contains:[{begin:/([^\u2401\u0001=]+)/,
-end:/=([^\u2401\u0001=]+)/,returnEnd:!0,returnBegin:!1,className:"attr"},{
-begin:/=/,end:/([\u2401\u0001])/,excludeEnd:!0,excludeBegin:!0,
-className:"string"}]}],case_insensitive:!0})})());
-hljs.registerLanguage("flix",(()=>{"use strict";return e=>({name:"Flix",
-keywords:{literal:"true false",
-keyword:"case class def else enum if impl import in lat rel index let match namespace switch type yield with"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},{className:"string",variants:[{begin:'"',
-end:'"'}]},{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,
-excludeEnd:!0,contains:[{className:"title",relevance:0,
-begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/
-}]},e.C_NUMBER_MODE]})})());
-hljs.registerLanguage("fortran",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a={variants:[n.COMMENT("!","$",{relevance:0
-}),n.COMMENT("^C[ ]","$",{relevance:0}),n.COMMENT("^C$","$",{relevance:0})]
-},t=/(_[a-z_\d]+)?/,i=/([de][+-]?\d+)?/,c={className:"number",variants:[{
-begin:e(/\b\d+/,/\.(\d*)/,i,t)},{begin:e(/\b\d+/,i,t)},{begin:e(/\.\d+/,i,t)}],
-relevance:0},o={className:"function",
-beginKeywords:"subroutine function program",illegal:"[${=\\n]",
-contains:[n.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]}
-;return{name:"Fortran",case_insensitive:!0,aliases:["f90","f95"],keywords:{
-literal:".False. .True.",
-keyword:"kind do concurrent local shared while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then block endblock endassociate public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated  c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure impure integer real character complex logical codimension dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data",
-built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_of acosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image sync change team co_broadcast co_max co_min co_sum co_reduce"
-},illegal:/\/\*/,contains:[{className:"string",relevance:0,
-variants:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE]},o,{begin:/^C\s*=(?!=)/,
-relevance:0},a,c]}}})());
-hljs.registerLanguage("fsharp",(()=>{"use strict";return e=>{const n={begin:"<",
-end:">",contains:[e.inherit(e.TITLE_MODE,{begin:/'[a-zA-Z0-9_]+/})]};return{
-name:"F#",aliases:["fs"],
-keywords:"abstract and as assert base begin class default delegate do done downcast downto elif else end exception extern false finally for fun function global if in inherit inline interface internal lazy let match member module mutable namespace new null of open or override private public rec return sig static struct then to true try type upcast use val void when while with yield",
-illegal:/\/\*/,contains:[{className:"keyword",begin:/\b(yield|return|let|do)!/
-},{className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},{
-className:"string",begin:'"""',end:'"""'},e.COMMENT("\\(\\*(\\s)","\\*\\)",{
-contains:["self"]}),{className:"class",beginKeywords:"type",end:"\\(|=|$",
-excludeEnd:!0,contains:[e.UNDERSCORE_TITLE_MODE,n]},{className:"meta",
-begin:"\\[<",end:">\\]",relevance:10},{className:"symbol",
-begin:"\\B('[A-Za-z])\\b",contains:[e.BACKSLASH_ESCAPE]
-},e.C_LINE_COMMENT_MODE,e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),e.C_NUMBER_MODE]}}})());
-hljs.registerLanguage("gams",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a={
-keyword:"abort acronym acronyms alias all and assign binary card diag display else eq file files for free ge gt if integer le loop lt maximizing minimizing model models ne negative no not option options or ord positive prod put putpage puttl repeat sameas semicont semiint smax smin solve sos1 sos2 sum system table then until using while xor yes",
-literal:"eps inf na",
-built_in:"abs arccos arcsin arctan arctan2 Beta betaReg binomial ceil centropy cos cosh cvPower div div0 eDist entropy errorf execSeed exp fact floor frac gamma gammaReg log logBeta logGamma log10 log2 mapVal max min mod ncpCM ncpF ncpVUpow ncpVUsin normal pi poly power randBinomial randLinear randTriangle round rPower sigmoid sign signPower sin sinh slexp sllog10 slrec sqexp sqlog10 sqr sqrec sqrt tan tanh trunc uniform uniformInt vcPower bool_and bool_eqv bool_imp bool_not bool_or bool_xor ifThen rel_eq rel_ge rel_gt rel_le rel_lt rel_ne gday gdow ghour gleap gmillisec gminute gmonth gsecond gyear jdate jnow jstart jtime errorLevel execError gamsRelease gamsVersion handleCollect handleDelete handleStatus handleSubmit heapFree heapLimit heapSize jobHandle jobKill jobStatus jobTerminate licenseLevel licenseStatus maxExecError sleep timeClose timeComp timeElapsed timeExec timeStart"
-},i={className:"symbol",variants:[{begin:/=[lgenxc]=/},{begin:/\$/}]},s={
-className:"comment",variants:[{begin:"'",end:"'"},{begin:'"',end:'"'}],
-illegal:"\\n",contains:[n.BACKSLASH_ESCAPE]},o={begin:"/",end:"/",keywords:a,
-contains:[s,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_NUMBER_MODE]
-},t=/[a-z0-9&#*=?@\\><:,()$[\]_.{}!+%^-]+/,r={
-begin:/[a-z][a-z0-9_]*(\([a-z0-9_, ]*\))?[ \t]+/,excludeBegin:!0,end:"$",
-endsWithParent:!0,contains:[s,o,{className:"comment",
-begin:e(t,(l=e(/[ ]+/,t),e("(",l,")*"))),relevance:0}]};var l;return{
-name:"GAMS",aliases:["gms"],case_insensitive:!0,keywords:a,
-contains:[n.COMMENT(/^\$ontext/,/^\$offtext/),{className:"meta",
-begin:"^\\$[a-z0-9]+",end:"$",returnBegin:!0,contains:[{
-className:"meta-keyword",begin:"^\\$[a-z0-9]+"}]
-},n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,{
-beginKeywords:"set sets parameter parameters variable variables scalar scalars equation equations",
-end:";",
-contains:[n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,o,r]
-},{beginKeywords:"table",end:";",returnBegin:!0,contains:[{
-beginKeywords:"table",end:"$",contains:[r]
-},n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_NUMBER_MODE]
-},{className:"function",begin:/^[a-z][a-z0-9_,\-+' ()$]+\.{2}/,returnBegin:!0,
-contains:[{className:"title",begin:/^[a-z0-9_]+/},{className:"params",
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0},i]},n.C_NUMBER_MODE,i]}}
-})());
-hljs.registerLanguage("gauss",(()=>{"use strict";return e=>{const t={
-keyword:"bool break call callexe checkinterrupt clear clearg closeall cls comlog compile continue create debug declare delete disable dlibrary dllcall do dos ed edit else elseif enable end endfor endif endp endo errorlog errorlogat expr external fn for format goto gosub graph if keyword let lib library line load loadarray loadexe loadf loadk loadm loadp loads loadx local locate loopnextindex lprint lpwidth lshow matrix msym ndpclex new open output outwidth plot plotsym pop prcsn print printdos proc push retp return rndcon rndmod rndmult rndseed run save saveall screen scroll setarray show sparse stop string struct system trace trap threadfor threadendfor threadbegin threadjoin threadstat threadend until use while winprint ne ge le gt lt and xor or not eq eqv",
-built_in:"abs acf aconcat aeye amax amean AmericanBinomCall AmericanBinomCall_Greeks AmericanBinomCall_ImpVol AmericanBinomPut AmericanBinomPut_Greeks AmericanBinomPut_ImpVol AmericanBSCall AmericanBSCall_Greeks AmericanBSCall_ImpVol AmericanBSPut AmericanBSPut_Greeks AmericanBSPut_ImpVol amin amult annotationGetDefaults annotationSetBkd annotationSetFont annotationSetLineColor annotationSetLineStyle annotationSetLineThickness annualTradingDays arccos arcsin areshape arrayalloc arrayindex arrayinit arraytomat asciiload asclabel astd astds asum atan atan2 atranspose axmargin balance band bandchol bandcholsol bandltsol bandrv bandsolpd bar base10 begwind besselj bessely beta box boxcox cdfBeta cdfBetaInv cdfBinomial cdfBinomialInv cdfBvn cdfBvn2 cdfBvn2e cdfCauchy cdfCauchyInv cdfChic cdfChii cdfChinc cdfChincInv cdfExp cdfExpInv cdfFc cdfFnc cdfFncInv cdfGam cdfGenPareto cdfHyperGeo cdfLaplace cdfLaplaceInv cdfLogistic cdfLogisticInv cdfmControlCreate cdfMvn cdfMvn2e cdfMvnce cdfMvne cdfMvt2e cdfMvtce cdfMvte cdfN cdfN2 cdfNc cdfNegBinomial cdfNegBinomialInv cdfNi cdfPoisson cdfPoissonInv cdfRayleigh cdfRayleighInv cdfTc cdfTci cdfTnc cdfTvn cdfWeibull cdfWeibullInv cdir ceil ChangeDir chdir chiBarSquare chol choldn cholsol cholup chrs close code cols colsf combinate combinated complex con cond conj cons ConScore contour conv convertsatostr convertstrtosa corrm corrms corrvc corrx corrxs cos cosh counts countwts crossprd crout croutp csrcol csrlin csvReadM csvReadSA cumprodc cumsumc curve cvtos datacreate datacreatecomplex datalist dataload dataloop dataopen datasave date datestr datestring datestrymd dayinyr dayofweek dbAddDatabase dbClose dbCommit dbCreateQuery dbExecQuery dbGetConnectOptions dbGetDatabaseName dbGetDriverName dbGetDrivers dbGetHostName dbGetLastErrorNum dbGetLastErrorText dbGetNumericalPrecPolicy dbGetPassword dbGetPort dbGetTableHeaders dbGetTables dbGetUserName dbHasFeature dbIsDriverAvailable dbIsOpen dbIsOpenError dbOpen dbQueryBindValue dbQueryClear dbQueryCols dbQueryExecPrepared dbQueryFetchAllM dbQueryFetchAllSA dbQueryFetchOneM dbQueryFetchOneSA dbQueryFinish dbQueryGetBoundValue dbQueryGetBoundValues dbQueryGetField dbQueryGetLastErrorNum dbQueryGetLastErrorText dbQueryGetLastInsertID dbQueryGetLastQuery dbQueryGetPosition dbQueryIsActive dbQueryIsForwardOnly dbQueryIsNull dbQueryIsSelect dbQueryIsValid dbQueryPrepare dbQueryRows dbQuerySeek dbQuerySeekFirst dbQuerySeekLast dbQuerySeekNext dbQuerySeekPrevious dbQuerySetForwardOnly dbRemoveDatabase dbRollback dbSetConnectOptions dbSetDatabaseName dbSetHostName dbSetNumericalPrecPolicy dbSetPort dbSetUserName dbTransaction DeleteFile delif delrows denseToSp denseToSpRE denToZero design det detl dfft dffti diag diagrv digamma doswin DOSWinCloseall DOSWinOpen dotfeq dotfeqmt dotfge dotfgemt dotfgt dotfgtmt dotfle dotflemt dotflt dotfltmt dotfne dotfnemt draw drop dsCreate dstat dstatmt dstatmtControlCreate dtdate dtday dttime dttodtv dttostr dttoutc dtvnormal dtvtodt dtvtoutc dummy dummybr dummydn eig eigh eighv eigv elapsedTradingDays endwind envget eof eqSolve eqSolvemt eqSolvemtControlCreate eqSolvemtOutCreate eqSolveset erf erfc erfccplx erfcplx error etdays ethsec etstr EuropeanBinomCall EuropeanBinomCall_Greeks EuropeanBinomCall_ImpVol EuropeanBinomPut EuropeanBinomPut_Greeks EuropeanBinomPut_ImpVol EuropeanBSCall EuropeanBSCall_Greeks EuropeanBSCall_ImpVol EuropeanBSPut EuropeanBSPut_Greeks EuropeanBSPut_ImpVol exctsmpl exec execbg exp extern eye fcheckerr fclearerr feq feqmt fflush fft ffti fftm fftmi fftn fge fgemt fgets fgetsa fgetsat fgetst fgt fgtmt fileinfo filesa fle flemt floor flt fltmt fmod fne fnemt fonts fopen formatcv formatnv fputs fputst fseek fstrerror ftell ftocv ftos ftostrC gamma gammacplx gammaii gausset gdaAppend gdaCreate gdaDStat gdaDStatMat gdaGetIndex gdaGetName gdaGetNames gdaGetOrders gdaGetType gdaGetTypes gdaGetVarInfo gdaIsCplx gdaLoad gdaPack gdaRead gdaReadByIndex gdaReadSome gdaReadSparse gdaReadStruct gdaReportVarInfo gdaSave gdaUpdate gdaUpdateAndPack gdaVars gdaWrite gdaWrite32 gdaWriteSome getarray getdims getf getGAUSShome getmatrix getmatrix4D getname getnamef getNextTradingDay getNextWeekDay getnr getorders getpath getPreviousTradingDay getPreviousWeekDay getRow getscalar3D getscalar4D getTrRow getwind glm gradcplx gradMT gradMTm gradMTT gradMTTm gradp graphprt graphset hasimag header headermt hess hessMT hessMTg hessMTgw hessMTm hessMTmw hessMTT hessMTTg hessMTTgw hessMTTm hessMTw hessp hist histf histp hsec imag indcv indexcat indices indices2 indicesf indicesfn indnv indsav integrate1d integrateControlCreate intgrat2 intgrat3 inthp1 inthp2 inthp3 inthp4 inthpControlCreate intquad1 intquad2 intquad3 intrleav intrleavsa intrsect intsimp inv invpd invswp iscplx iscplxf isden isinfnanmiss ismiss key keyav keyw lag lag1 lagn lapEighb lapEighi lapEighvb lapEighvi lapgEig lapgEigh lapgEighv lapgEigv lapgSchur lapgSvdcst lapgSvds lapgSvdst lapSvdcusv lapSvds lapSvdusv ldlp ldlsol linSolve listwise ln lncdfbvn lncdfbvn2 lncdfmvn lncdfn lncdfn2 lncdfnc lnfact lngammacplx lnpdfmvn lnpdfmvt lnpdfn lnpdft loadd loadstruct loadwind loess loessmt loessmtControlCreate log loglog logx logy lower lowmat lowmat1 ltrisol lu lusol machEpsilon make makevars makewind margin matalloc matinit mattoarray maxbytes maxc maxindc maxv maxvec mbesselei mbesselei0 mbesselei1 mbesseli mbesseli0 mbesseli1 meanc median mergeby mergevar minc minindc minv miss missex missrv moment momentd movingave movingaveExpwgt movingaveWgt nextindex nextn nextnevn nextwind ntos null null1 numCombinations ols olsmt olsmtControlCreate olsqr olsqr2 olsqrmt ones optn optnevn orth outtyp pacf packedToSp packr parse pause pdfCauchy pdfChi pdfExp pdfGenPareto pdfHyperGeo pdfLaplace pdfLogistic pdfn pdfPoisson pdfRayleigh pdfWeibull pi pinv pinvmt plotAddArrow plotAddBar plotAddBox plotAddHist plotAddHistF plotAddHistP plotAddPolar plotAddScatter plotAddShape plotAddTextbox plotAddTS plotAddXY plotArea plotBar plotBox plotClearLayout plotContour plotCustomLayout plotGetDefaults plotHist plotHistF plotHistP plotLayout plotLogLog plotLogX plotLogY plotOpenWindow plotPolar plotSave plotScatter plotSetAxesPen plotSetBar plotSetBarFill plotSetBarStacked plotSetBkdColor plotSetFill plotSetGrid plotSetLegend plotSetLineColor plotSetLineStyle plotSetLineSymbol plotSetLineThickness plotSetNewWindow plotSetTitle plotSetWhichYAxis plotSetXAxisShow plotSetXLabel plotSetXRange plotSetXTicInterval plotSetXTicLabel plotSetYAxisShow plotSetYLabel plotSetYRange plotSetZAxisShow plotSetZLabel plotSurface plotTS plotXY polar polychar polyeval polygamma polyint polymake polymat polymroot polymult polyroot pqgwin previousindex princomp printfm printfmt prodc psi putarray putf putvals pvCreate pvGetIndex pvGetParNames pvGetParVector pvLength pvList pvPack pvPacki pvPackm pvPackmi pvPacks pvPacksi pvPacksm pvPacksmi pvPutParVector pvTest pvUnpack QNewton QNewtonmt QNewtonmtControlCreate QNewtonmtOutCreate QNewtonSet QProg QProgmt QProgmtInCreate qqr qqre qqrep qr qre qrep qrsol qrtsol qtyr qtyre qtyrep quantile quantiled qyr qyre qyrep qz rank rankindx readr real reclassify reclassifyCuts recode recserar recsercp recserrc rerun rescale reshape rets rev rfft rffti rfftip rfftn rfftnp rfftp rndBernoulli rndBeta rndBinomial rndCauchy rndChiSquare rndCon rndCreateState rndExp rndGamma rndGeo rndGumbel rndHyperGeo rndi rndKMbeta rndKMgam rndKMi rndKMn rndKMnb rndKMp rndKMu rndKMvm rndLaplace rndLCbeta rndLCgam rndLCi rndLCn rndLCnb rndLCp rndLCu rndLCvm rndLogNorm rndMTu rndMVn rndMVt rndn rndnb rndNegBinomial rndp rndPoisson rndRayleigh rndStateSkip rndu rndvm rndWeibull rndWishart rotater round rows rowsf rref sampleData satostrC saved saveStruct savewind scale scale3d scalerr scalinfnanmiss scalmiss schtoc schur searchsourcepath seekr select selif seqa seqm setdif setdifsa setvars setvwrmode setwind shell shiftr sin singleindex sinh sleep solpd sortc sortcc sortd sorthc sorthcc sortind sortindc sortmc sortr sortrc spBiconjGradSol spChol spConjGradSol spCreate spDenseSubmat spDiagRvMat spEigv spEye spLDL spline spLU spNumNZE spOnes spreadSheetReadM spreadSheetReadSA spreadSheetWrite spScale spSubmat spToDense spTrTDense spTScalar spZeros sqpSolve sqpSolveMT sqpSolveMTControlCreate sqpSolveMTlagrangeCreate sqpSolveMToutCreate sqpSolveSet sqrt statements stdc stdsc stocv stof strcombine strindx strlen strput strrindx strsect strsplit strsplitPad strtodt strtof strtofcplx strtriml strtrimr strtrunc strtruncl strtruncpad strtruncr submat subscat substute subvec sumc sumr surface svd svd1 svd2 svdcusv svds svdusv sysstate tab tan tanh tempname time timedt timestr timeutc title tkf2eps tkf2ps tocart todaydt toeplitz token topolar trapchk trigamma trimr trunc type typecv typef union unionsa uniqindx uniqindxsa unique uniquesa upmat upmat1 upper utctodt utctodtv utrisol vals varCovMS varCovXS varget vargetl varmall varmares varput varputl vartypef vcm vcms vcx vcxs vec vech vecr vector vget view viewxyz vlist vnamecv volume vput vread vtypecv wait waitc walkindex where window writer xlabel xlsGetSheetCount xlsGetSheetSize xlsGetSheetTypes xlsMakeRange xlsReadM xlsReadSA xlsWrite xlsWriteM xlsWriteSA xpnd xtics xy xyz ylabel ytics zeros zeta zlabel ztics cdfEmpirical dot h5create h5open h5read h5readAttribute h5write h5writeAttribute ldl plotAddErrorBar plotAddSurface plotCDFEmpirical plotSetColormap plotSetContourLabels plotSetLegendFont plotSetTextInterpreter plotSetXTicCount plotSetYTicCount plotSetZLevels powerm strjoin sylvester strtrim",
-literal:"DB_AFTER_LAST_ROW DB_ALL_TABLES DB_BATCH_OPERATIONS DB_BEFORE_FIRST_ROW DB_BLOB DB_EVENT_NOTIFICATIONS DB_FINISH_QUERY DB_HIGH_PRECISION DB_LAST_INSERT_ID DB_LOW_PRECISION_DOUBLE DB_LOW_PRECISION_INT32 DB_LOW_PRECISION_INT64 DB_LOW_PRECISION_NUMBERS DB_MULTIPLE_RESULT_SETS DB_NAMED_PLACEHOLDERS DB_POSITIONAL_PLACEHOLDERS DB_PREPARED_QUERIES DB_QUERY_SIZE DB_SIMPLE_LOCKING DB_SYSTEM_TABLES DB_TABLES DB_TRANSACTIONS DB_UNICODE DB_VIEWS __STDIN __STDOUT __STDERR __FILE_DIR"
-},a=e.COMMENT("@","@"),r={className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":"define definecs|10 undef ifdef ifndef iflight ifdllcall ifmac ifos2win ifunix else endif lineson linesoff srcfile srcline"
-},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",
-keywords:{"meta-keyword":"include"},contains:[{className:"meta-string",
-begin:'"',end:'"',illegal:"\\n"}]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a]},n={begin:/\bstruct\s+/,
-end:/\s/,keywords:"struct",contains:[{className:"type",
-begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},s=[{className:"params",begin:/\(/,
-end:/\)/,excludeBegin:!0,excludeEnd:!0,endsWithParent:!0,relevance:0,contains:[{
-className:"literal",begin:/\.\.\./},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,n]
-}],o={className:"title",begin:e.UNDERSCORE_IDENT_RE,relevance:0},d=(t,r,n)=>{
-const d=e.inherit({className:"function",beginKeywords:t,end:r,excludeEnd:!0,
-contains:[].concat(s)},n||{})
-;return d.contains.push(o),d.contains.push(e.C_NUMBER_MODE),
-d.contains.push(e.C_BLOCK_COMMENT_MODE),d.contains.push(a),d},l={
-className:"built_in",begin:"\\b("+t.built_in.split(" ").join("|")+")\\b"},i={
-className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE],relevance:0
-},c={begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,keywords:t,
-relevance:0,contains:[{beginKeywords:t.keyword},l,{className:"built_in",
-begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},p={begin:/\(/,end:/\)/,relevance:0,
-keywords:{built_in:t.built_in,literal:t.literal},
-contains:[e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,l,c,i,"self"]}
-;return c.contains.push(p),{name:"GAUSS",aliases:["gss"],case_insensitive:!0,
-keywords:t,illegal:/(\{[%#]|[%#]\}| <- )/,
-contains:[e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,i,r,{
-className:"keyword",
-begin:/\bexternal (matrix|string|array|sparse matrix|struct|proc|keyword|fn)/
-},d("proc keyword",";"),d("fn","="),{beginKeywords:"for threadfor",end:/;/,
-relevance:0,contains:[e.C_BLOCK_COMMENT_MODE,a,p]},{variants:[{
-begin:e.UNDERSCORE_IDENT_RE+"\\."+e.UNDERSCORE_IDENT_RE},{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*="}],relevance:0},c,n]}}})());
-hljs.registerLanguage("gcode",(()=>{"use strict";return e=>{
-const a=e.inherit(e.C_NUMBER_MODE,{
-begin:"([-+]?((\\.\\d+)|(\\d+)(\\.\\d*)?))|"+e.C_NUMBER_RE
-}),n=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT(/\(/,/\)/),a,e.inherit(e.APOS_STRING_MODE,{
-illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"name",
-begin:"([G])([0-9]+\\.?[0-9]?)"},{className:"name",
-begin:"([M])([0-9]+\\.?[0-9]?)"},{className:"attr",begin:"(VC|VS|#)",
-end:"(\\d+)"},{className:"attr",begin:"(VZOFX|VZOFY|VZOFZ)"},{
-className:"built_in",
-begin:"(ATAN|ABS|ACOS|ASIN|SIN|COS|EXP|FIX|FUP|ROUND|LN|TAN)(\\[)",contains:[a],
-end:"\\]"},{className:"symbol",variants:[{begin:"N",end:"\\d+",illegal:"\\W"}]}]
-;return{name:"G-code (ISO 6983)",aliases:["nc"],case_insensitive:!0,keywords:{
-$pattern:"[A-Z_][A-Z0-9_.]*",
-keyword:"IF DO WHILE ENDWHILE CALL ENDIF SUB ENDSUB GOTO REPEAT ENDREPEAT EQ LT GT NE GE LE OR XOR"
-},contains:[{className:"meta",begin:"%"},{className:"meta",begin:"([O])([0-9]+)"
-}].concat(n)}}})());
-hljs.registerLanguage("gherkin",(()=>{"use strict";return e=>({name:"Gherkin",
-aliases:["feature"],
-keywords:"Feature Background Ability Business Need Scenario Scenarios Scenario Outline Scenario Template Examples Given And Then But When",
-contains:[{className:"symbol",begin:"\\*",relevance:0},{className:"meta",
-begin:"@[^@\\s]+"},{begin:"\\|",end:"\\|\\w*$",contains:[{className:"string",
-begin:"[^|]+"}]},{className:"variable",begin:"<",end:">"},e.HASH_COMMENT_MODE,{
-className:"string",begin:'"""',end:'"""'},e.QUOTE_STRING_MODE]})})());
-hljs.registerLanguage("glsl",(()=>{"use strict";return e=>({name:"GLSL",
-keywords:{
-keyword:"break continue discard do else for if return while switch case default attribute binding buffer ccw centroid centroid varying coherent column_major const cw depth_any depth_greater depth_less depth_unchanged early_fragment_tests equal_spacing flat fractional_even_spacing fractional_odd_spacing highp in index inout invariant invocations isolines layout line_strip lines lines_adjacency local_size_x local_size_y local_size_z location lowp max_vertices mediump noperspective offset origin_upper_left out packed patch pixel_center_integer point_mode points precise precision quads r11f_g11f_b10f r16 r16_snorm r16f r16i r16ui r32f r32i r32ui r8 r8_snorm r8i r8ui readonly restrict rg16 rg16_snorm rg16f rg16i rg16ui rg32f rg32i rg32ui rg8 rg8_snorm rg8i rg8ui rgb10_a2 rgb10_a2ui rgba16 rgba16_snorm rgba16f rgba16i rgba16ui rgba32f rgba32i rgba32ui rgba8 rgba8_snorm rgba8i rgba8ui row_major sample shared smooth std140 std430 stream triangle_strip triangles triangles_adjacency uniform varying vertices volatile writeonly",
-type:"atomic_uint bool bvec2 bvec3 bvec4 dmat2 dmat2x2 dmat2x3 dmat2x4 dmat3 dmat3x2 dmat3x3 dmat3x4 dmat4 dmat4x2 dmat4x3 dmat4x4 double dvec2 dvec3 dvec4 float iimage1D iimage1DArray iimage2D iimage2DArray iimage2DMS iimage2DMSArray iimage2DRect iimage3D iimageBuffer iimageCube iimageCubeArray image1D image1DArray image2D image2DArray image2DMS image2DMSArray image2DRect image3D imageBuffer imageCube imageCubeArray int isampler1D isampler1DArray isampler2D isampler2DArray isampler2DMS isampler2DMSArray isampler2DRect isampler3D isamplerBuffer isamplerCube isamplerCubeArray ivec2 ivec3 ivec4 mat2 mat2x2 mat2x3 mat2x4 mat3 mat3x2 mat3x3 mat3x4 mat4 mat4x2 mat4x3 mat4x4 sampler1D sampler1DArray sampler1DArrayShadow sampler1DShadow sampler2D sampler2DArray sampler2DArrayShadow sampler2DMS sampler2DMSArray sampler2DRect sampler2DRectShadow sampler2DShadow sampler3D samplerBuffer samplerCube samplerCubeArray samplerCubeArrayShadow samplerCubeShadow image1D uimage1DArray uimage2D uimage2DArray uimage2DMS uimage2DMSArray uimage2DRect uimage3D uimageBuffer uimageCube uimageCubeArray uint usampler1D usampler1DArray usampler2D usampler2DArray usampler2DMS usampler2DMSArray usampler2DRect usampler3D samplerBuffer usamplerCube usamplerCubeArray uvec2 uvec3 uvec4 vec2 vec3 vec4 void",
-built_in:"gl_MaxAtomicCounterBindings gl_MaxAtomicCounterBufferSize gl_MaxClipDistances gl_MaxClipPlanes gl_MaxCombinedAtomicCounterBuffers gl_MaxCombinedAtomicCounters gl_MaxCombinedImageUniforms gl_MaxCombinedImageUnitsAndFragmentOutputs gl_MaxCombinedTextureImageUnits gl_MaxComputeAtomicCounterBuffers gl_MaxComputeAtomicCounters gl_MaxComputeImageUniforms gl_MaxComputeTextureImageUnits gl_MaxComputeUniformComponents gl_MaxComputeWorkGroupCount gl_MaxComputeWorkGroupSize gl_MaxDrawBuffers gl_MaxFragmentAtomicCounterBuffers gl_MaxFragmentAtomicCounters gl_MaxFragmentImageUniforms gl_MaxFragmentInputComponents gl_MaxFragmentInputVectors gl_MaxFragmentUniformComponents gl_MaxFragmentUniformVectors gl_MaxGeometryAtomicCounterBuffers gl_MaxGeometryAtomicCounters gl_MaxGeometryImageUniforms gl_MaxGeometryInputComponents gl_MaxGeometryOutputComponents gl_MaxGeometryOutputVertices gl_MaxGeometryTextureImageUnits gl_MaxGeometryTotalOutputComponents gl_MaxGeometryUniformComponents gl_MaxGeometryVaryingComponents gl_MaxImageSamples gl_MaxImageUnits gl_MaxLights gl_MaxPatchVertices gl_MaxProgramTexelOffset gl_MaxTessControlAtomicCounterBuffers gl_MaxTessControlAtomicCounters gl_MaxTessControlImageUniforms gl_MaxTessControlInputComponents gl_MaxTessControlOutputComponents gl_MaxTessControlTextureImageUnits gl_MaxTessControlTotalOutputComponents gl_MaxTessControlUniformComponents gl_MaxTessEvaluationAtomicCounterBuffers gl_MaxTessEvaluationAtomicCounters gl_MaxTessEvaluationImageUniforms gl_MaxTessEvaluationInputComponents gl_MaxTessEvaluationOutputComponents gl_MaxTessEvaluationTextureImageUnits gl_MaxTessEvaluationUniformComponents gl_MaxTessGenLevel gl_MaxTessPatchComponents gl_MaxTextureCoords gl_MaxTextureImageUnits gl_MaxTextureUnits gl_MaxVaryingComponents gl_MaxVaryingFloats gl_MaxVaryingVectors gl_MaxVertexAtomicCounterBuffers gl_MaxVertexAtomicCounters gl_MaxVertexAttribs gl_MaxVertexImageUniforms gl_MaxVertexOutputComponents gl_MaxVertexOutputVectors gl_MaxVertexTextureImageUnits gl_MaxVertexUniformComponents gl_MaxVertexUniformVectors gl_MaxViewports gl_MinProgramTexelOffset gl_BackColor gl_BackLightModelProduct gl_BackLightProduct gl_BackMaterial gl_BackSecondaryColor gl_ClipDistance gl_ClipPlane gl_ClipVertex gl_Color gl_DepthRange gl_EyePlaneQ gl_EyePlaneR gl_EyePlaneS gl_EyePlaneT gl_Fog gl_FogCoord gl_FogFragCoord gl_FragColor gl_FragCoord gl_FragData gl_FragDepth gl_FrontColor gl_FrontFacing gl_FrontLightModelProduct gl_FrontLightProduct gl_FrontMaterial gl_FrontSecondaryColor gl_GlobalInvocationID gl_InstanceID gl_InvocationID gl_Layer gl_LightModel gl_LightSource gl_LocalInvocationID gl_LocalInvocationIndex gl_ModelViewMatrix gl_ModelViewMatrixInverse gl_ModelViewMatrixInverseTranspose gl_ModelViewMatrixTranspose gl_ModelViewProjectionMatrix gl_ModelViewProjectionMatrixInverse gl_ModelViewProjectionMatrixInverseTranspose gl_ModelViewProjectionMatrixTranspose gl_MultiTexCoord0 gl_MultiTexCoord1 gl_MultiTexCoord2 gl_MultiTexCoord3 gl_MultiTexCoord4 gl_MultiTexCoord5 gl_MultiTexCoord6 gl_MultiTexCoord7 gl_Normal gl_NormalMatrix gl_NormalScale gl_NumSamples gl_NumWorkGroups gl_ObjectPlaneQ gl_ObjectPlaneR gl_ObjectPlaneS gl_ObjectPlaneT gl_PatchVerticesIn gl_Point gl_PointCoord gl_PointSize gl_Position gl_PrimitiveID gl_PrimitiveIDIn gl_ProjectionMatrix gl_ProjectionMatrixInverse gl_ProjectionMatrixInverseTranspose gl_ProjectionMatrixTranspose gl_SampleID gl_SampleMask gl_SampleMaskIn gl_SamplePosition gl_SecondaryColor gl_TessCoord gl_TessLevelInner gl_TessLevelOuter gl_TexCoord gl_TextureEnvColor gl_TextureMatrix gl_TextureMatrixInverse gl_TextureMatrixInverseTranspose gl_TextureMatrixTranspose gl_Vertex gl_VertexID gl_ViewportIndex gl_WorkGroupID gl_WorkGroupSize gl_in gl_out EmitStreamVertex EmitVertex EndPrimitive EndStreamPrimitive abs acos acosh all any asin asinh atan atanh atomicAdd atomicAnd atomicCompSwap atomicCounter atomicCounterDecrement atomicCounterIncrement atomicExchange atomicMax atomicMin atomicOr atomicXor barrier bitCount bitfieldExtract bitfieldInsert bitfieldReverse ceil clamp cos cosh cross dFdx dFdy degrees determinant distance dot equal exp exp2 faceforward findLSB findMSB floatBitsToInt floatBitsToUint floor fma fract frexp ftransform fwidth greaterThan greaterThanEqual groupMemoryBarrier imageAtomicAdd imageAtomicAnd imageAtomicCompSwap imageAtomicExchange imageAtomicMax imageAtomicMin imageAtomicOr imageAtomicXor imageLoad imageSize imageStore imulExtended intBitsToFloat interpolateAtCentroid interpolateAtOffset interpolateAtSample inverse inversesqrt isinf isnan ldexp length lessThan lessThanEqual log log2 matrixCompMult max memoryBarrier memoryBarrierAtomicCounter memoryBarrierBuffer memoryBarrierImage memoryBarrierShared min mix mod modf noise1 noise2 noise3 noise4 normalize not notEqual outerProduct packDouble2x32 packHalf2x16 packSnorm2x16 packSnorm4x8 packUnorm2x16 packUnorm4x8 pow radians reflect refract round roundEven shadow1D shadow1DLod shadow1DProj shadow1DProjLod shadow2D shadow2DLod shadow2DProj shadow2DProjLod sign sin sinh smoothstep sqrt step tan tanh texelFetch texelFetchOffset texture texture1D texture1DLod texture1DProj texture1DProjLod texture2D texture2DLod texture2DProj texture2DProjLod texture3D texture3DLod texture3DProj texture3DProjLod textureCube textureCubeLod textureGather textureGatherOffset textureGatherOffsets textureGrad textureGradOffset textureLod textureLodOffset textureOffset textureProj textureProjGrad textureProjGradOffset textureProjLod textureProjLodOffset textureProjOffset textureQueryLevels textureQueryLod textureSize transpose trunc uaddCarry uintBitsToFloat umulExtended unpackDouble2x32 unpackHalf2x16 unpackSnorm2x16 unpackSnorm4x8 unpackUnorm2x16 unpackUnorm4x8 usubBorrow",
-literal:"true false"},illegal:'"',
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"}]})})());
-hljs.registerLanguage("gml",(()=>{"use strict";return e=>({name:"GML",
-case_insensitive:!1,keywords:{
-keyword:"begin end if then else while do for break continue with until repeat exit and or xor not return mod div switch case default var globalvar enum function constructor delete #macro #region #endregion",
-built_in:"is_real is_string is_array is_undefined is_int32 is_int64 is_ptr is_vec3 is_vec4 is_matrix is_bool is_method is_struct is_infinity is_nan is_numeric typeof variable_global_exists variable_global_get variable_global_set variable_instance_exists variable_instance_get variable_instance_set variable_instance_get_names variable_struct_exists variable_struct_get variable_struct_get_names variable_struct_names_count variable_struct_remove variable_struct_set array_delete array_insert array_length array_length_1d array_length_2d array_height_2d array_equals array_create array_copy array_pop array_push array_resize array_sort random random_range irandom irandom_range random_set_seed random_get_seed randomize randomise choose abs round floor ceil sign frac sqrt sqr exp ln log2 log10 sin cos tan arcsin arccos arctan arctan2 dsin dcos dtan darcsin darccos darctan darctan2 degtorad radtodeg power logn min max mean median clamp lerp dot_product dot_product_3d dot_product_normalised dot_product_3d_normalised dot_product_normalized dot_product_3d_normalized math_set_epsilon math_get_epsilon angle_difference point_distance_3d point_distance point_direction lengthdir_x lengthdir_y real string int64 ptr string_format chr ansi_char ord string_length string_byte_length string_pos string_copy string_char_at string_ord_at string_byte_at string_set_byte_at string_delete string_insert string_lower string_upper string_repeat string_letters string_digits string_lettersdigits string_replace string_replace_all string_count string_hash_to_newline clipboard_has_text clipboard_set_text clipboard_get_text date_current_datetime date_create_datetime date_valid_datetime date_inc_year date_inc_month date_inc_week date_inc_day date_inc_hour date_inc_minute date_inc_second date_get_year date_get_month date_get_week date_get_day date_get_hour date_get_minute date_get_second date_get_weekday date_get_day_of_year date_get_hour_of_year date_get_minute_of_year date_get_second_of_year date_year_span date_month_span date_week_span date_day_span date_hour_span date_minute_span date_second_span date_compare_datetime date_compare_date date_compare_time date_date_of date_time_of date_datetime_string date_date_string date_time_string date_days_in_month date_days_in_year date_leap_year date_is_today date_set_timezone date_get_timezone game_set_speed game_get_speed motion_set motion_add place_free place_empty place_meeting place_snapped move_random move_snap move_towards_point move_contact_solid move_contact_all move_outside_solid move_outside_all move_bounce_solid move_bounce_all move_wrap distance_to_point distance_to_object position_empty position_meeting path_start path_end mp_linear_step mp_potential_step mp_linear_step_object mp_potential_step_object mp_potential_settings mp_linear_path mp_potential_path mp_linear_path_object mp_potential_path_object mp_grid_create mp_grid_destroy mp_grid_clear_all mp_grid_clear_cell mp_grid_clear_rectangle mp_grid_add_cell mp_grid_get_cell mp_grid_add_rectangle mp_grid_add_instances mp_grid_path mp_grid_draw mp_grid_to_ds_grid collision_point collision_rectangle collision_circle collision_ellipse collision_line collision_point_list collision_rectangle_list collision_circle_list collision_ellipse_list collision_line_list instance_position_list instance_place_list point_in_rectangle point_in_triangle point_in_circle rectangle_in_rectangle rectangle_in_triangle rectangle_in_circle instance_find instance_exists instance_number instance_position instance_nearest instance_furthest instance_place instance_create_depth instance_create_layer instance_copy instance_change instance_destroy position_destroy position_change instance_id_get instance_deactivate_all instance_deactivate_object instance_deactivate_region instance_activate_all instance_activate_object instance_activate_region room_goto room_goto_previous room_goto_next room_previous room_next room_restart game_end game_restart game_load game_save game_save_buffer game_load_buffer event_perform event_user event_perform_object event_inherited show_debug_message show_debug_overlay debug_event debug_get_callstack alarm_get alarm_set font_texture_page_size keyboard_set_map keyboard_get_map keyboard_unset_map keyboard_check keyboard_check_pressed keyboard_check_released keyboard_check_direct keyboard_get_numlock keyboard_set_numlock keyboard_key_press keyboard_key_release keyboard_clear io_clear mouse_check_button mouse_check_button_pressed mouse_check_button_released mouse_wheel_up mouse_wheel_down mouse_clear draw_self draw_sprite draw_sprite_pos draw_sprite_ext draw_sprite_stretched draw_sprite_stretched_ext draw_sprite_tiled draw_sprite_tiled_ext draw_sprite_part draw_sprite_part_ext draw_sprite_general draw_clear draw_clear_alpha draw_point draw_line draw_line_width draw_rectangle draw_roundrect draw_roundrect_ext draw_triangle draw_circle draw_ellipse draw_set_circle_precision draw_arrow draw_button draw_path draw_healthbar draw_getpixel draw_getpixel_ext draw_set_colour draw_set_color draw_set_alpha draw_get_colour draw_get_color draw_get_alpha merge_colour make_colour_rgb make_colour_hsv colour_get_red colour_get_green colour_get_blue colour_get_hue colour_get_saturation colour_get_value merge_color make_color_rgb make_color_hsv color_get_red color_get_green color_get_blue color_get_hue color_get_saturation color_get_value merge_color screen_save screen_save_part draw_set_font draw_set_halign draw_set_valign draw_text draw_text_ext string_width string_height string_width_ext string_height_ext draw_text_transformed draw_text_ext_transformed draw_text_colour draw_text_ext_colour draw_text_transformed_colour draw_text_ext_transformed_colour draw_text_color draw_text_ext_color draw_text_transformed_color draw_text_ext_transformed_color draw_point_colour draw_line_colour draw_line_width_colour draw_rectangle_colour draw_roundrect_colour draw_roundrect_colour_ext draw_triangle_colour draw_circle_colour draw_ellipse_colour draw_point_color draw_line_color draw_line_width_color draw_rectangle_color draw_roundrect_color draw_roundrect_color_ext draw_triangle_color draw_circle_color draw_ellipse_color draw_primitive_begin draw_vertex draw_vertex_colour draw_vertex_color draw_primitive_end sprite_get_uvs font_get_uvs sprite_get_texture font_get_texture texture_get_width texture_get_height texture_get_uvs draw_primitive_begin_texture draw_vertex_texture draw_vertex_texture_colour draw_vertex_texture_color texture_global_scale surface_create surface_create_ext surface_resize surface_free surface_exists surface_get_width surface_get_height surface_get_texture surface_set_target surface_set_target_ext surface_reset_target surface_depth_disable surface_get_depth_disable draw_surface draw_surface_stretched draw_surface_tiled draw_surface_part draw_surface_ext draw_surface_stretched_ext draw_surface_tiled_ext draw_surface_part_ext draw_surface_general surface_getpixel surface_getpixel_ext surface_save surface_save_part surface_copy surface_copy_part application_surface_draw_enable application_get_position application_surface_enable application_surface_is_enabled display_get_width display_get_height display_get_orientation display_get_gui_width display_get_gui_height display_reset display_mouse_get_x display_mouse_get_y display_mouse_set display_set_ui_visibility window_set_fullscreen window_get_fullscreen window_set_caption window_set_min_width window_set_max_width window_set_min_height window_set_max_height window_get_visible_rects window_get_caption window_set_cursor window_get_cursor window_set_colour window_get_colour window_set_color window_get_color window_set_position window_set_size window_set_rectangle window_center window_get_x window_get_y window_get_width window_get_height window_mouse_get_x window_mouse_get_y window_mouse_set window_view_mouse_get_x window_view_mouse_get_y window_views_mouse_get_x window_views_mouse_get_y audio_listener_position audio_listener_velocity audio_listener_orientation audio_emitter_position audio_emitter_create audio_emitter_free audio_emitter_exists audio_emitter_pitch audio_emitter_velocity audio_emitter_falloff audio_emitter_gain audio_play_sound audio_play_sound_on audio_play_sound_at audio_stop_sound audio_resume_music audio_music_is_playing audio_resume_sound audio_pause_sound audio_pause_music audio_channel_num audio_sound_length audio_get_type audio_falloff_set_model audio_play_music audio_stop_music audio_master_gain audio_music_gain audio_sound_gain audio_sound_pitch audio_stop_all audio_resume_all audio_pause_all audio_is_playing audio_is_paused audio_exists audio_sound_set_track_position audio_sound_get_track_position audio_emitter_get_gain audio_emitter_get_pitch audio_emitter_get_x audio_emitter_get_y audio_emitter_get_z audio_emitter_get_vx audio_emitter_get_vy audio_emitter_get_vz audio_listener_set_position audio_listener_set_velocity audio_listener_set_orientation audio_listener_get_data audio_set_master_gain audio_get_master_gain audio_sound_get_gain audio_sound_get_pitch audio_get_name audio_sound_set_track_position audio_sound_get_track_position audio_create_stream audio_destroy_stream audio_create_sync_group audio_destroy_sync_group audio_play_in_sync_group audio_start_sync_group audio_stop_sync_group audio_pause_sync_group audio_resume_sync_group audio_sync_group_get_track_pos audio_sync_group_debug audio_sync_group_is_playing audio_debug audio_group_load audio_group_unload audio_group_is_loaded audio_group_load_progress audio_group_name audio_group_stop_all audio_group_set_gain audio_create_buffer_sound audio_free_buffer_sound audio_create_play_queue audio_free_play_queue audio_queue_sound audio_get_recorder_count audio_get_recorder_info audio_start_recording audio_stop_recording audio_sound_get_listener_mask audio_emitter_get_listener_mask audio_get_listener_mask audio_sound_set_listener_mask audio_emitter_set_listener_mask audio_set_listener_mask audio_get_listener_count audio_get_listener_info audio_system show_message show_message_async clickable_add clickable_add_ext clickable_change clickable_change_ext clickable_delete clickable_exists clickable_set_style show_question show_question_async get_integer get_string get_integer_async get_string_async get_login_async get_open_filename get_save_filename get_open_filename_ext get_save_filename_ext show_error highscore_clear highscore_add highscore_value highscore_name draw_highscore sprite_exists sprite_get_name sprite_get_number sprite_get_width sprite_get_height sprite_get_xoffset sprite_get_yoffset sprite_get_bbox_left sprite_get_bbox_right sprite_get_bbox_top sprite_get_bbox_bottom sprite_save sprite_save_strip sprite_set_cache_size sprite_set_cache_size_ext sprite_get_tpe sprite_prefetch sprite_prefetch_multi sprite_flush sprite_flush_multi sprite_set_speed sprite_get_speed_type sprite_get_speed font_exists font_get_name font_get_fontname font_get_bold font_get_italic font_get_first font_get_last font_get_size font_set_cache_size path_exists path_get_name path_get_length path_get_time path_get_kind path_get_closed path_get_precision path_get_number path_get_point_x path_get_point_y path_get_point_speed path_get_x path_get_y path_get_speed script_exists script_get_name timeline_add timeline_delete timeline_clear timeline_exists timeline_get_name timeline_moment_clear timeline_moment_add_script timeline_size timeline_max_moment object_exists object_get_name object_get_sprite object_get_solid object_get_visible object_get_persistent object_get_mask object_get_parent object_get_physics object_is_ancestor room_exists room_get_name sprite_set_offset sprite_duplicate sprite_assign sprite_merge sprite_add sprite_replace sprite_create_from_surface sprite_add_from_surface sprite_delete sprite_set_alpha_from_sprite sprite_collision_mask font_add_enable_aa font_add_get_enable_aa font_add font_add_sprite font_add_sprite_ext font_replace font_replace_sprite font_replace_sprite_ext font_delete path_set_kind path_set_closed path_set_precision path_add path_assign path_duplicate path_append path_delete path_add_point path_insert_point path_change_point path_delete_point path_clear_points path_reverse path_mirror path_flip path_rotate path_rescale path_shift script_execute object_set_sprite object_set_solid object_set_visible object_set_persistent object_set_mask room_set_width room_set_height room_set_persistent room_set_background_colour room_set_background_color room_set_view room_set_viewport room_get_viewport room_set_view_enabled room_add room_duplicate room_assign room_instance_add room_instance_clear room_get_camera room_set_camera asset_get_index asset_get_type file_text_open_from_string file_text_open_read file_text_open_write file_text_open_append file_text_close file_text_write_string file_text_write_real file_text_writeln file_text_read_string file_text_read_real file_text_readln file_text_eof file_text_eoln file_exists file_delete file_rename file_copy directory_exists directory_create directory_destroy file_find_first file_find_next file_find_close file_attributes filename_name filename_path filename_dir filename_drive filename_ext filename_change_ext file_bin_open file_bin_rewrite file_bin_close file_bin_position file_bin_size file_bin_seek file_bin_write_byte file_bin_read_byte parameter_count parameter_string environment_get_variable ini_open_from_string ini_open ini_close ini_read_string ini_read_real ini_write_string ini_write_real ini_key_exists ini_section_exists ini_key_delete ini_section_delete ds_set_precision ds_exists ds_stack_create ds_stack_destroy ds_stack_clear ds_stack_copy ds_stack_size ds_stack_empty ds_stack_push ds_stack_pop ds_stack_top ds_stack_write ds_stack_read ds_queue_create ds_queue_destroy ds_queue_clear ds_queue_copy ds_queue_size ds_queue_empty ds_queue_enqueue ds_queue_dequeue ds_queue_head ds_queue_tail ds_queue_write ds_queue_read ds_list_create ds_list_destroy ds_list_clear ds_list_copy ds_list_size ds_list_empty ds_list_add ds_list_insert ds_list_replace ds_list_delete ds_list_find_index ds_list_find_value ds_list_mark_as_list ds_list_mark_as_map ds_list_sort ds_list_shuffle ds_list_write ds_list_read ds_list_set ds_map_create ds_map_destroy ds_map_clear ds_map_copy ds_map_size ds_map_empty ds_map_add ds_map_add_list ds_map_add_map ds_map_replace ds_map_replace_map ds_map_replace_list ds_map_delete ds_map_exists ds_map_find_value ds_map_find_previous ds_map_find_next ds_map_find_first ds_map_find_last ds_map_write ds_map_read ds_map_secure_save ds_map_secure_load ds_map_secure_load_buffer ds_map_secure_save_buffer ds_map_set ds_priority_create ds_priority_destroy ds_priority_clear ds_priority_copy ds_priority_size ds_priority_empty ds_priority_add ds_priority_change_priority ds_priority_find_priority ds_priority_delete_value ds_priority_delete_min ds_priority_find_min ds_priority_delete_max ds_priority_find_max ds_priority_write ds_priority_read ds_grid_create ds_grid_destroy ds_grid_copy ds_grid_resize ds_grid_width ds_grid_height ds_grid_clear ds_grid_set ds_grid_add ds_grid_multiply ds_grid_set_region ds_grid_add_region ds_grid_multiply_region ds_grid_set_disk ds_grid_add_disk ds_grid_multiply_disk ds_grid_set_grid_region ds_grid_add_grid_region ds_grid_multiply_grid_region ds_grid_get ds_grid_get_sum ds_grid_get_max ds_grid_get_min ds_grid_get_mean ds_grid_get_disk_sum ds_grid_get_disk_min ds_grid_get_disk_max ds_grid_get_disk_mean ds_grid_value_exists ds_grid_value_x ds_grid_value_y ds_grid_value_disk_exists ds_grid_value_disk_x ds_grid_value_disk_y ds_grid_shuffle ds_grid_write ds_grid_read ds_grid_sort ds_grid_set ds_grid_get effect_create_below effect_create_above effect_clear part_type_create part_type_destroy part_type_exists part_type_clear part_type_shape part_type_sprite part_type_size part_type_scale part_type_orientation part_type_life part_type_step part_type_death part_type_speed part_type_direction part_type_gravity part_type_colour1 part_type_colour2 part_type_colour3 part_type_colour_mix part_type_colour_rgb part_type_colour_hsv part_type_color1 part_type_color2 part_type_color3 part_type_color_mix part_type_color_rgb part_type_color_hsv part_type_alpha1 part_type_alpha2 part_type_alpha3 part_type_blend part_system_create part_system_create_layer part_system_destroy part_system_exists part_system_clear part_system_draw_order part_system_depth part_system_position part_system_automatic_update part_system_automatic_draw part_system_update part_system_drawit part_system_get_layer part_system_layer part_particles_create part_particles_create_colour part_particles_create_color part_particles_clear part_particles_count part_emitter_create part_emitter_destroy part_emitter_destroy_all part_emitter_exists part_emitter_clear part_emitter_region part_emitter_burst part_emitter_stream external_call external_define external_free window_handle window_device matrix_get matrix_set matrix_build_identity matrix_build matrix_build_lookat matrix_build_projection_ortho matrix_build_projection_perspective matrix_build_projection_perspective_fov matrix_multiply matrix_transform_vertex matrix_stack_push matrix_stack_pop matrix_stack_multiply matrix_stack_set matrix_stack_clear matrix_stack_top matrix_stack_is_empty browser_input_capture os_get_config os_get_info os_get_language os_get_region os_lock_orientation display_get_dpi_x display_get_dpi_y display_set_gui_size display_set_gui_maximise display_set_gui_maximize device_mouse_dbclick_enable display_set_timing_method display_get_timing_method display_set_sleep_margin display_get_sleep_margin virtual_key_add virtual_key_hide virtual_key_delete virtual_key_show draw_enable_drawevent draw_enable_swf_aa draw_set_swf_aa_level draw_get_swf_aa_level draw_texture_flush draw_flush gpu_set_blendenable gpu_set_ztestenable gpu_set_zfunc gpu_set_zwriteenable gpu_set_lightingenable gpu_set_fog gpu_set_cullmode gpu_set_blendmode gpu_set_blendmode_ext gpu_set_blendmode_ext_sepalpha gpu_set_colorwriteenable gpu_set_colourwriteenable gpu_set_alphatestenable gpu_set_alphatestref gpu_set_alphatestfunc gpu_set_texfilter gpu_set_texfilter_ext gpu_set_texrepeat gpu_set_texrepeat_ext gpu_set_tex_filter gpu_set_tex_filter_ext gpu_set_tex_repeat gpu_set_tex_repeat_ext gpu_set_tex_mip_filter gpu_set_tex_mip_filter_ext gpu_set_tex_mip_bias gpu_set_tex_mip_bias_ext gpu_set_tex_min_mip gpu_set_tex_min_mip_ext gpu_set_tex_max_mip gpu_set_tex_max_mip_ext gpu_set_tex_max_aniso gpu_set_tex_max_aniso_ext gpu_set_tex_mip_enable gpu_set_tex_mip_enable_ext gpu_get_blendenable gpu_get_ztestenable gpu_get_zfunc gpu_get_zwriteenable gpu_get_lightingenable gpu_get_fog gpu_get_cullmode gpu_get_blendmode gpu_get_blendmode_ext gpu_get_blendmode_ext_sepalpha gpu_get_blendmode_src gpu_get_blendmode_dest gpu_get_blendmode_srcalpha gpu_get_blendmode_destalpha gpu_get_colorwriteenable gpu_get_colourwriteenable gpu_get_alphatestenable gpu_get_alphatestref gpu_get_alphatestfunc gpu_get_texfilter gpu_get_texfilter_ext gpu_get_texrepeat gpu_get_texrepeat_ext gpu_get_tex_filter gpu_get_tex_filter_ext gpu_get_tex_repeat gpu_get_tex_repeat_ext gpu_get_tex_mip_filter gpu_get_tex_mip_filter_ext gpu_get_tex_mip_bias gpu_get_tex_mip_bias_ext gpu_get_tex_min_mip gpu_get_tex_min_mip_ext gpu_get_tex_max_mip gpu_get_tex_max_mip_ext gpu_get_tex_max_aniso gpu_get_tex_max_aniso_ext gpu_get_tex_mip_enable gpu_get_tex_mip_enable_ext gpu_push_state gpu_pop_state gpu_get_state gpu_set_state draw_light_define_ambient draw_light_define_direction draw_light_define_point draw_light_enable draw_set_lighting draw_light_get_ambient draw_light_get draw_get_lighting shop_leave_rating url_get_domain url_open url_open_ext url_open_full get_timer achievement_login achievement_logout achievement_post achievement_increment achievement_post_score achievement_available achievement_show_achievements achievement_show_leaderboards achievement_load_friends achievement_load_leaderboard achievement_send_challenge achievement_load_progress achievement_reset achievement_login_status achievement_get_pic achievement_show_challenge_notifications achievement_get_challenges achievement_event achievement_show achievement_get_info cloud_file_save cloud_string_save cloud_synchronise ads_enable ads_disable ads_setup ads_engagement_launch ads_engagement_available ads_engagement_active ads_event ads_event_preload ads_set_reward_callback ads_get_display_height ads_get_display_width ads_move ads_interstitial_available ads_interstitial_display device_get_tilt_x device_get_tilt_y device_get_tilt_z device_is_keypad_open device_mouse_check_button device_mouse_check_button_pressed device_mouse_check_button_released device_mouse_x device_mouse_y device_mouse_raw_x device_mouse_raw_y device_mouse_x_to_gui device_mouse_y_to_gui iap_activate iap_status iap_enumerate_products iap_restore_all iap_acquire iap_consume iap_product_details iap_purchase_details facebook_init facebook_login facebook_status facebook_graph_request facebook_dialog facebook_logout facebook_launch_offerwall facebook_post_message facebook_send_invite facebook_user_id facebook_accesstoken facebook_check_permission facebook_request_read_permissions facebook_request_publish_permissions gamepad_is_supported gamepad_get_device_count gamepad_is_connected gamepad_get_description gamepad_get_button_threshold gamepad_set_button_threshold gamepad_get_axis_deadzone gamepad_set_axis_deadzone gamepad_button_count gamepad_button_check gamepad_button_check_pressed gamepad_button_check_released gamepad_button_value gamepad_axis_count gamepad_axis_value gamepad_set_vibration gamepad_set_colour gamepad_set_color os_is_paused window_has_focus code_is_compiled http_get http_get_file http_post_string http_request json_encode json_decode zip_unzip load_csv base64_encode base64_decode md5_string_unicode md5_string_utf8 md5_file os_is_network_connected sha1_string_unicode sha1_string_utf8 sha1_file os_powersave_enable analytics_event analytics_event_ext win8_livetile_tile_notification win8_livetile_tile_clear win8_livetile_badge_notification win8_livetile_badge_clear win8_livetile_queue_enable win8_secondarytile_pin win8_secondarytile_badge_notification win8_secondarytile_delete win8_livetile_notification_begin win8_livetile_notification_secondary_begin win8_livetile_notification_expiry win8_livetile_notification_tag win8_livetile_notification_text_add win8_livetile_notification_image_add win8_livetile_notification_end win8_appbar_enable win8_appbar_add_element win8_appbar_remove_element win8_settingscharm_add_entry win8_settingscharm_add_html_entry win8_settingscharm_add_xaml_entry win8_settingscharm_set_xaml_property win8_settingscharm_get_xaml_property win8_settingscharm_remove_entry win8_share_image win8_share_screenshot win8_share_file win8_share_url win8_share_text win8_search_enable win8_search_disable win8_search_add_suggestions win8_device_touchscreen_available win8_license_initialize_sandbox win8_license_trial_version winphone_license_trial_version winphone_tile_title winphone_tile_count winphone_tile_back_title winphone_tile_back_content winphone_tile_back_content_wide winphone_tile_front_image winphone_tile_front_image_small winphone_tile_front_image_wide winphone_tile_back_image winphone_tile_back_image_wide winphone_tile_background_colour winphone_tile_background_color winphone_tile_icon_image winphone_tile_small_icon_image winphone_tile_wide_content winphone_tile_cycle_images winphone_tile_small_background_image physics_world_create physics_world_gravity physics_world_update_speed physics_world_update_iterations physics_world_draw_debug physics_pause_enable physics_fixture_create physics_fixture_set_kinematic physics_fixture_set_density physics_fixture_set_awake physics_fixture_set_restitution physics_fixture_set_friction physics_fixture_set_collision_group physics_fixture_set_sensor physics_fixture_set_linear_damping physics_fixture_set_angular_damping physics_fixture_set_circle_shape physics_fixture_set_box_shape physics_fixture_set_edge_shape physics_fixture_set_polygon_shape physics_fixture_set_chain_shape physics_fixture_add_point physics_fixture_bind physics_fixture_bind_ext physics_fixture_delete physics_apply_force physics_apply_impulse physics_apply_angular_impulse physics_apply_local_force physics_apply_local_impulse physics_apply_torque physics_mass_properties physics_draw_debug physics_test_overlap physics_remove_fixture physics_set_friction physics_set_density physics_set_restitution physics_get_friction physics_get_density physics_get_restitution physics_joint_distance_create physics_joint_rope_create physics_joint_revolute_create physics_joint_prismatic_create physics_joint_pulley_create physics_joint_wheel_create physics_joint_weld_create physics_joint_friction_create physics_joint_gear_create physics_joint_enable_motor physics_joint_get_value physics_joint_set_value physics_joint_delete physics_particle_create physics_particle_delete physics_particle_delete_region_circle physics_particle_delete_region_box physics_particle_delete_region_poly physics_particle_set_flags physics_particle_set_category_flags physics_particle_draw physics_particle_draw_ext physics_particle_count physics_particle_get_data physics_particle_get_data_particle physics_particle_group_begin physics_particle_group_circle physics_particle_group_box physics_particle_group_polygon physics_particle_group_add_point physics_particle_group_end physics_particle_group_join physics_particle_group_delete physics_particle_group_count physics_particle_group_get_data physics_particle_group_get_mass physics_particle_group_get_inertia physics_particle_group_get_centre_x physics_particle_group_get_centre_y physics_particle_group_get_vel_x physics_particle_group_get_vel_y physics_particle_group_get_ang_vel physics_particle_group_get_x physics_particle_group_get_y physics_particle_group_get_angle physics_particle_set_group_flags physics_particle_get_group_flags physics_particle_get_max_count physics_particle_get_radius physics_particle_get_density physics_particle_get_damping physics_particle_get_gravity_scale physics_particle_set_max_count physics_particle_set_radius physics_particle_set_density physics_particle_set_damping physics_particle_set_gravity_scale network_create_socket network_create_socket_ext network_create_server network_create_server_raw network_connect network_connect_raw network_send_packet network_send_raw network_send_broadcast network_send_udp network_send_udp_raw network_set_timeout network_set_config network_resolve network_destroy buffer_create buffer_write buffer_read buffer_seek buffer_get_surface buffer_set_surface buffer_delete buffer_exists buffer_get_type buffer_get_alignment buffer_poke buffer_peek buffer_save buffer_save_ext buffer_load buffer_load_ext buffer_load_partial buffer_copy buffer_fill buffer_get_size buffer_tell buffer_resize buffer_md5 buffer_sha1 buffer_base64_encode buffer_base64_decode buffer_base64_decode_ext buffer_sizeof buffer_get_address buffer_create_from_vertex_buffer buffer_create_from_vertex_buffer_ext buffer_copy_from_vertex_buffer buffer_async_group_begin buffer_async_group_option buffer_async_group_end buffer_load_async buffer_save_async gml_release_mode gml_pragma steam_activate_overlay steam_is_overlay_enabled steam_is_overlay_activated steam_get_persona_name steam_initialised steam_is_cloud_enabled_for_app steam_is_cloud_enabled_for_account steam_file_persisted steam_get_quota_total steam_get_quota_free steam_file_write steam_file_write_file steam_file_read steam_file_delete steam_file_exists steam_file_size steam_file_share steam_is_screenshot_requested steam_send_screenshot steam_is_user_logged_on steam_get_user_steam_id steam_user_owns_dlc steam_user_installed_dlc steam_set_achievement steam_get_achievement steam_clear_achievement steam_set_stat_int steam_set_stat_float steam_set_stat_avg_rate steam_get_stat_int steam_get_stat_float steam_get_stat_avg_rate steam_reset_all_stats steam_reset_all_stats_achievements steam_stats_ready steam_create_leaderboard steam_upload_score steam_upload_score_ext steam_download_scores_around_user steam_download_scores steam_download_friends_scores steam_upload_score_buffer steam_upload_score_buffer_ext steam_current_game_language steam_available_languages steam_activate_overlay_browser steam_activate_overlay_user steam_activate_overlay_store steam_get_user_persona_name steam_get_app_id steam_get_user_account_id steam_ugc_download steam_ugc_create_item steam_ugc_start_item_update steam_ugc_set_item_title steam_ugc_set_item_description steam_ugc_set_item_visibility steam_ugc_set_item_tags steam_ugc_set_item_content steam_ugc_set_item_preview steam_ugc_submit_item_update steam_ugc_get_item_update_progress steam_ugc_subscribe_item steam_ugc_unsubscribe_item steam_ugc_num_subscribed_items steam_ugc_get_subscribed_items steam_ugc_get_item_install_info steam_ugc_get_item_update_info steam_ugc_request_item_details steam_ugc_create_query_user steam_ugc_create_query_user_ex steam_ugc_create_query_all steam_ugc_create_query_all_ex steam_ugc_query_set_cloud_filename_filter steam_ugc_query_set_match_any_tag steam_ugc_query_set_search_text steam_ugc_query_set_ranked_by_trend_days steam_ugc_query_add_required_tag steam_ugc_query_add_excluded_tag steam_ugc_query_set_return_long_description steam_ugc_query_set_return_total_only steam_ugc_query_set_allow_cached_response steam_ugc_send_query shader_set shader_get_name shader_reset shader_current shader_is_compiled shader_get_sampler_index shader_get_uniform shader_set_uniform_i shader_set_uniform_i_array shader_set_uniform_f shader_set_uniform_f_array shader_set_uniform_matrix shader_set_uniform_matrix_array shader_enable_corner_id texture_set_stage texture_get_texel_width texture_get_texel_height shaders_are_supported vertex_format_begin vertex_format_end vertex_format_delete vertex_format_add_position vertex_format_add_position_3d vertex_format_add_colour vertex_format_add_color vertex_format_add_normal vertex_format_add_texcoord vertex_format_add_textcoord vertex_format_add_custom vertex_create_buffer vertex_create_buffer_ext vertex_delete_buffer vertex_begin vertex_end vertex_position vertex_position_3d vertex_colour vertex_color vertex_argb vertex_texcoord vertex_normal vertex_float1 vertex_float2 vertex_float3 vertex_float4 vertex_ubyte4 vertex_submit vertex_freeze vertex_get_number vertex_get_buffer_size vertex_create_buffer_from_buffer vertex_create_buffer_from_buffer_ext push_local_notification push_get_first_local_notification push_get_next_local_notification push_cancel_local_notification skeleton_animation_set skeleton_animation_get skeleton_animation_mix skeleton_animation_set_ext skeleton_animation_get_ext skeleton_animation_get_duration skeleton_animation_get_frames skeleton_animation_clear skeleton_skin_set skeleton_skin_get skeleton_attachment_set skeleton_attachment_get skeleton_attachment_create skeleton_collision_draw_set skeleton_bone_data_get skeleton_bone_data_set skeleton_bone_state_get skeleton_bone_state_set skeleton_get_minmax skeleton_get_num_bounds skeleton_get_bounds skeleton_animation_get_frame skeleton_animation_set_frame draw_skeleton draw_skeleton_time draw_skeleton_instance draw_skeleton_collision skeleton_animation_list skeleton_skin_list skeleton_slot_data layer_get_id layer_get_id_at_depth layer_get_depth layer_create layer_destroy layer_destroy_instances layer_add_instance layer_has_instance layer_set_visible layer_get_visible layer_exists layer_x layer_y layer_get_x layer_get_y layer_hspeed layer_vspeed layer_get_hspeed layer_get_vspeed layer_script_begin layer_script_end layer_shader layer_get_script_begin layer_get_script_end layer_get_shader layer_set_target_room layer_get_target_room layer_reset_target_room layer_get_all layer_get_all_elements layer_get_name layer_depth layer_get_element_layer layer_get_element_type layer_element_move layer_force_draw_depth layer_is_draw_depth_forced layer_get_forced_depth layer_background_get_id layer_background_exists layer_background_create layer_background_destroy layer_background_visible layer_background_change layer_background_sprite layer_background_htiled layer_background_vtiled layer_background_stretch layer_background_yscale layer_background_xscale layer_background_blend layer_background_alpha layer_background_index layer_background_speed layer_background_get_visible layer_background_get_sprite layer_background_get_htiled layer_background_get_vtiled layer_background_get_stretch layer_background_get_yscale layer_background_get_xscale layer_background_get_blend layer_background_get_alpha layer_background_get_index layer_background_get_speed layer_sprite_get_id layer_sprite_exists layer_sprite_create layer_sprite_destroy layer_sprite_change layer_sprite_index layer_sprite_speed layer_sprite_xscale layer_sprite_yscale layer_sprite_angle layer_sprite_blend layer_sprite_alpha layer_sprite_x layer_sprite_y layer_sprite_get_sprite layer_sprite_get_index layer_sprite_get_speed layer_sprite_get_xscale layer_sprite_get_yscale layer_sprite_get_angle layer_sprite_get_blend layer_sprite_get_alpha layer_sprite_get_x layer_sprite_get_y layer_tilemap_get_id layer_tilemap_exists layer_tilemap_create layer_tilemap_destroy tilemap_tileset tilemap_x tilemap_y tilemap_set tilemap_set_at_pixel tilemap_get_tileset tilemap_get_tile_width tilemap_get_tile_height tilemap_get_width tilemap_get_height tilemap_get_x tilemap_get_y tilemap_get tilemap_get_at_pixel tilemap_get_cell_x_at_pixel tilemap_get_cell_y_at_pixel tilemap_clear draw_tilemap draw_tile tilemap_set_global_mask tilemap_get_global_mask tilemap_set_mask tilemap_get_mask tilemap_get_frame tile_set_empty tile_set_index tile_set_flip tile_set_mirror tile_set_rotate tile_get_empty tile_get_index tile_get_flip tile_get_mirror tile_get_rotate layer_tile_exists layer_tile_create layer_tile_destroy layer_tile_change layer_tile_xscale layer_tile_yscale layer_tile_blend layer_tile_alpha layer_tile_x layer_tile_y layer_tile_region layer_tile_visible layer_tile_get_sprite layer_tile_get_xscale layer_tile_get_yscale layer_tile_get_blend layer_tile_get_alpha layer_tile_get_x layer_tile_get_y layer_tile_get_region layer_tile_get_visible layer_instance_get_instance instance_activate_layer instance_deactivate_layer camera_create camera_create_view camera_destroy camera_apply camera_get_active camera_get_default camera_set_default camera_set_view_mat camera_set_proj_mat camera_set_update_script camera_set_begin_script camera_set_end_script camera_set_view_pos camera_set_view_size camera_set_view_speed camera_set_view_border camera_set_view_angle camera_set_view_target camera_get_view_mat camera_get_proj_mat camera_get_update_script camera_get_begin_script camera_get_end_script camera_get_view_x camera_get_view_y camera_get_view_width camera_get_view_height camera_get_view_speed_x camera_get_view_speed_y camera_get_view_border_x camera_get_view_border_y camera_get_view_angle camera_get_view_target view_get_camera view_get_visible view_get_xport view_get_yport view_get_wport view_get_hport view_get_surface_id view_set_camera view_set_visible view_set_xport view_set_yport view_set_wport view_set_hport view_set_surface_id gesture_drag_time gesture_drag_distance gesture_flick_speed gesture_double_tap_time gesture_double_tap_distance gesture_pinch_distance gesture_pinch_angle_towards gesture_pinch_angle_away gesture_rotate_time gesture_rotate_angle gesture_tap_count gesture_get_drag_time gesture_get_drag_distance gesture_get_flick_speed gesture_get_double_tap_time gesture_get_double_tap_distance gesture_get_pinch_distance gesture_get_pinch_angle_towards gesture_get_pinch_angle_away gesture_get_rotate_time gesture_get_rotate_angle gesture_get_tap_count keyboard_virtual_show keyboard_virtual_hide keyboard_virtual_status keyboard_virtual_height",
-literal:"self other all noone global local undefined pointer_invalid pointer_null path_action_stop path_action_restart path_action_continue path_action_reverse true false pi GM_build_date GM_version GM_runtime_version  timezone_local timezone_utc gamespeed_fps gamespeed_microseconds  ev_create ev_destroy ev_step ev_alarm ev_keyboard ev_mouse ev_collision ev_other ev_draw ev_draw_begin ev_draw_end ev_draw_pre ev_draw_post ev_keypress ev_keyrelease ev_trigger ev_left_button ev_right_button ev_middle_button ev_no_button ev_left_press ev_right_press ev_middle_press ev_left_release ev_right_release ev_middle_release ev_mouse_enter ev_mouse_leave ev_mouse_wheel_up ev_mouse_wheel_down ev_global_left_button ev_global_right_button ev_global_middle_button ev_global_left_press ev_global_right_press ev_global_middle_press ev_global_left_release ev_global_right_release ev_global_middle_release ev_joystick1_left ev_joystick1_right ev_joystick1_up ev_joystick1_down ev_joystick1_button1 ev_joystick1_button2 ev_joystick1_button3 ev_joystick1_button4 ev_joystick1_button5 ev_joystick1_button6 ev_joystick1_button7 ev_joystick1_button8 ev_joystick2_left ev_joystick2_right ev_joystick2_up ev_joystick2_down ev_joystick2_button1 ev_joystick2_button2 ev_joystick2_button3 ev_joystick2_button4 ev_joystick2_button5 ev_joystick2_button6 ev_joystick2_button7 ev_joystick2_button8 ev_outside ev_boundary ev_game_start ev_game_end ev_room_start ev_room_end ev_no_more_lives ev_animation_end ev_end_of_path ev_no_more_health ev_close_button ev_user0 ev_user1 ev_user2 ev_user3 ev_user4 ev_user5 ev_user6 ev_user7 ev_user8 ev_user9 ev_user10 ev_user11 ev_user12 ev_user13 ev_user14 ev_user15 ev_step_normal ev_step_begin ev_step_end ev_gui ev_gui_begin ev_gui_end ev_cleanup ev_gesture ev_gesture_tap ev_gesture_double_tap ev_gesture_drag_start ev_gesture_dragging ev_gesture_drag_end ev_gesture_flick ev_gesture_pinch_start ev_gesture_pinch_in ev_gesture_pinch_out ev_gesture_pinch_end ev_gesture_rotate_start ev_gesture_rotating ev_gesture_rotate_end ev_global_gesture_tap ev_global_gesture_double_tap ev_global_gesture_drag_start ev_global_gesture_dragging ev_global_gesture_drag_end ev_global_gesture_flick ev_global_gesture_pinch_start ev_global_gesture_pinch_in ev_global_gesture_pinch_out ev_global_gesture_pinch_end ev_global_gesture_rotate_start ev_global_gesture_rotating ev_global_gesture_rotate_end vk_nokey vk_anykey vk_enter vk_return vk_shift vk_control vk_alt vk_escape vk_space vk_backspace vk_tab vk_pause vk_printscreen vk_left vk_right vk_up vk_down vk_home vk_end vk_delete vk_insert vk_pageup vk_pagedown vk_f1 vk_f2 vk_f3 vk_f4 vk_f5 vk_f6 vk_f7 vk_f8 vk_f9 vk_f10 vk_f11 vk_f12 vk_numpad0 vk_numpad1 vk_numpad2 vk_numpad3 vk_numpad4 vk_numpad5 vk_numpad6 vk_numpad7 vk_numpad8 vk_numpad9 vk_divide vk_multiply vk_subtract vk_add vk_decimal vk_lshift vk_lcontrol vk_lalt vk_rshift vk_rcontrol vk_ralt  mb_any mb_none mb_left mb_right mb_middle c_aqua c_black c_blue c_dkgray c_fuchsia c_gray c_green c_lime c_ltgray c_maroon c_navy c_olive c_purple c_red c_silver c_teal c_white c_yellow c_orange fa_left fa_center fa_right fa_top fa_middle fa_bottom pr_pointlist pr_linelist pr_linestrip pr_trianglelist pr_trianglestrip pr_trianglefan bm_complex bm_normal bm_add bm_max bm_subtract bm_zero bm_one bm_src_colour bm_inv_src_colour bm_src_color bm_inv_src_color bm_src_alpha bm_inv_src_alpha bm_dest_alpha bm_inv_dest_alpha bm_dest_colour bm_inv_dest_colour bm_dest_color bm_inv_dest_color bm_src_alpha_sat tf_point tf_linear tf_anisotropic mip_off mip_on mip_markedonly audio_falloff_none audio_falloff_inverse_distance audio_falloff_inverse_distance_clamped audio_falloff_linear_distance audio_falloff_linear_distance_clamped audio_falloff_exponent_distance audio_falloff_exponent_distance_clamped audio_old_system audio_new_system audio_mono audio_stereo audio_3d cr_default cr_none cr_arrow cr_cross cr_beam cr_size_nesw cr_size_ns cr_size_nwse cr_size_we cr_uparrow cr_hourglass cr_drag cr_appstart cr_handpoint cr_size_all spritespeed_framespersecond spritespeed_framespergameframe asset_object asset_unknown asset_sprite asset_sound asset_room asset_path asset_script asset_font asset_timeline asset_tiles asset_shader fa_readonly fa_hidden fa_sysfile fa_volumeid fa_directory fa_archive  ds_type_map ds_type_list ds_type_stack ds_type_queue ds_type_grid ds_type_priority ef_explosion ef_ring ef_ellipse ef_firework ef_smoke ef_smokeup ef_star ef_spark ef_flare ef_cloud ef_rain ef_snow pt_shape_pixel pt_shape_disk pt_shape_square pt_shape_line pt_shape_star pt_shape_circle pt_shape_ring pt_shape_sphere pt_shape_flare pt_shape_spark pt_shape_explosion pt_shape_cloud pt_shape_smoke pt_shape_snow ps_distr_linear ps_distr_gaussian ps_distr_invgaussian ps_shape_rectangle ps_shape_ellipse ps_shape_diamond ps_shape_line ty_real ty_string dll_cdecl dll_stdcall matrix_view matrix_projection matrix_world os_win32 os_windows os_macosx os_ios os_android os_symbian os_linux os_unknown os_winphone os_tizen os_win8native os_wiiu os_3ds  os_psvita os_bb10 os_ps4 os_xboxone os_ps3 os_xbox360 os_uwp os_tvos os_switch browser_not_a_browser browser_unknown browser_ie browser_firefox browser_chrome browser_safari browser_safari_mobile browser_opera browser_tizen browser_edge browser_windows_store browser_ie_mobile  device_ios_unknown device_ios_iphone device_ios_iphone_retina device_ios_ipad device_ios_ipad_retina device_ios_iphone5 device_ios_iphone6 device_ios_iphone6plus device_emulator device_tablet display_landscape display_landscape_flipped display_portrait display_portrait_flipped tm_sleep tm_countvsyncs of_challenge_win of_challen ge_lose of_challenge_tie leaderboard_type_number leaderboard_type_time_mins_secs cmpfunc_never cmpfunc_less cmpfunc_equal cmpfunc_lessequal cmpfunc_greater cmpfunc_notequal cmpfunc_greaterequal cmpfunc_always cull_noculling cull_clockwise cull_counterclockwise lighttype_dir lighttype_point iap_ev_storeload iap_ev_product iap_ev_purchase iap_ev_consume iap_ev_restore iap_storeload_ok iap_storeload_failed iap_status_uninitialised iap_status_unavailable iap_status_loading iap_status_available iap_status_processing iap_status_restoring iap_failed iap_unavailable iap_available iap_purchased iap_canceled iap_refunded fb_login_default fb_login_fallback_to_webview fb_login_no_fallback_to_webview fb_login_forcing_webview fb_login_use_system_account fb_login_forcing_safari  phy_joint_anchor_1_x phy_joint_anchor_1_y phy_joint_anchor_2_x phy_joint_anchor_2_y phy_joint_reaction_force_x phy_joint_reaction_force_y phy_joint_reaction_torque phy_joint_motor_speed phy_joint_angle phy_joint_motor_torque phy_joint_max_motor_torque phy_joint_translation phy_joint_speed phy_joint_motor_force phy_joint_max_motor_force phy_joint_length_1 phy_joint_length_2 phy_joint_damping_ratio phy_joint_frequency phy_joint_lower_angle_limit phy_joint_upper_angle_limit phy_joint_angle_limits phy_joint_max_length phy_joint_max_torque phy_joint_max_force phy_debug_render_aabb phy_debug_render_collision_pairs phy_debug_render_coms phy_debug_render_core_shapes phy_debug_render_joints phy_debug_render_obb phy_debug_render_shapes  phy_particle_flag_water phy_particle_flag_zombie phy_particle_flag_wall phy_particle_flag_spring phy_particle_flag_elastic phy_particle_flag_viscous phy_particle_flag_powder phy_particle_flag_tensile phy_particle_flag_colourmixing phy_particle_flag_colormixing phy_particle_group_flag_solid phy_particle_group_flag_rigid phy_particle_data_flag_typeflags phy_particle_data_flag_position phy_particle_data_flag_velocity phy_particle_data_flag_colour phy_particle_data_flag_color phy_particle_data_flag_category  achievement_our_info achievement_friends_info achievement_leaderboard_info achievement_achievement_info achievement_filter_all_players achievement_filter_friends_only achievement_filter_favorites_only achievement_type_achievement_challenge achievement_type_score_challenge achievement_pic_loaded  achievement_show_ui achievement_show_profile achievement_show_leaderboard achievement_show_achievement achievement_show_bank achievement_show_friend_picker achievement_show_purchase_prompt network_socket_tcp network_socket_udp network_socket_bluetooth network_type_connect network_type_disconnect network_type_data network_type_non_blocking_connect network_config_connect_timeout network_config_use_non_blocking_socket network_config_enable_reliable_udp network_config_disable_reliable_udp buffer_fixed buffer_grow buffer_wrap buffer_fast buffer_vbuffer buffer_network buffer_u8 buffer_s8 buffer_u16 buffer_s16 buffer_u32 buffer_s32 buffer_u64 buffer_f16 buffer_f32 buffer_f64 buffer_bool buffer_text buffer_string buffer_surface_copy buffer_seek_start buffer_seek_relative buffer_seek_end buffer_generalerror buffer_outofspace buffer_outofbounds buffer_invalidtype  text_type button_type input_type ANSI_CHARSET DEFAULT_CHARSET EASTEUROPE_CHARSET RUSSIAN_CHARSET SYMBOL_CHARSET SHIFTJIS_CHARSET HANGEUL_CHARSET GB2312_CHARSET CHINESEBIG5_CHARSET JOHAB_CHARSET HEBREW_CHARSET ARABIC_CHARSET GREEK_CHARSET TURKISH_CHARSET VIETNAMESE_CHARSET THAI_CHARSET MAC_CHARSET BALTIC_CHARSET OEM_CHARSET  gp_face1 gp_face2 gp_face3 gp_face4 gp_shoulderl gp_shoulderr gp_shoulderlb gp_shoulderrb gp_select gp_start gp_stickl gp_stickr gp_padu gp_padd gp_padl gp_padr gp_axislh gp_axislv gp_axisrh gp_axisrv ov_friends ov_community ov_players ov_settings ov_gamegroup ov_achievements lb_sort_none lb_sort_ascending lb_sort_descending lb_disp_none lb_disp_numeric lb_disp_time_sec lb_disp_time_ms ugc_result_success ugc_filetype_community ugc_filetype_microtrans ugc_visibility_public ugc_visibility_friends_only ugc_visibility_private ugc_query_RankedByVote ugc_query_RankedByPublicationDate ugc_query_AcceptedForGameRankedByAcceptanceDate ugc_query_RankedByTrend ugc_query_FavoritedByFriendsRankedByPublicationDate ugc_query_CreatedByFriendsRankedByPublicationDate ugc_query_RankedByNumTimesReported ugc_query_CreatedByFollowedUsersRankedByPublicationDate ugc_query_NotYetRated ugc_query_RankedByTotalVotesAsc ugc_query_RankedByVotesUp ugc_query_RankedByTextSearch ugc_sortorder_CreationOrderDesc ugc_sortorder_CreationOrderAsc ugc_sortorder_TitleAsc ugc_sortorder_LastUpdatedDesc ugc_sortorder_SubscriptionDateDesc ugc_sortorder_VoteScoreDesc ugc_sortorder_ForModeration ugc_list_Published ugc_list_VotedOn ugc_list_VotedUp ugc_list_VotedDown ugc_list_WillVoteLater ugc_list_Favorited ugc_list_Subscribed ugc_list_UsedOrPlayed ugc_list_Followed ugc_match_Items ugc_match_Items_Mtx ugc_match_Items_ReadyToUse ugc_match_Collections ugc_match_Artwork ugc_match_Videos ugc_match_Screenshots ugc_match_AllGuides ugc_match_WebGuides ugc_match_IntegratedGuides ugc_match_UsableInGame ugc_match_ControllerBindings  vertex_usage_position vertex_usage_colour vertex_usage_color vertex_usage_normal vertex_usage_texcoord vertex_usage_textcoord vertex_usage_blendweight vertex_usage_blendindices vertex_usage_psize vertex_usage_tangent vertex_usage_binormal vertex_usage_fog vertex_usage_depth vertex_usage_sample vertex_type_float1 vertex_type_float2 vertex_type_float3 vertex_type_float4 vertex_type_colour vertex_type_color vertex_type_ubyte4 layerelementtype_undefined layerelementtype_background layerelementtype_instance layerelementtype_oldtilemap layerelementtype_sprite layerelementtype_tilemap layerelementtype_particlesystem layerelementtype_tile tile_rotate tile_flip tile_mirror tile_index_mask kbv_type_default kbv_type_ascii kbv_type_url kbv_type_email kbv_type_numbers kbv_type_phone kbv_type_phone_name kbv_returnkey_default kbv_returnkey_go kbv_returnkey_google kbv_returnkey_join kbv_returnkey_next kbv_returnkey_route kbv_returnkey_search kbv_returnkey_send kbv_returnkey_yahoo kbv_returnkey_done kbv_returnkey_continue kbv_returnkey_emergency kbv_autocapitalize_none kbv_autocapitalize_words kbv_autocapitalize_sentences kbv_autocapitalize_characters",
-symbol:"argument_relative argument argument0 argument1 argument2 argument3 argument4 argument5 argument6 argument7 argument8 argument9 argument10 argument11 argument12 argument13 argument14 argument15 argument_count x|0 y|0 xprevious yprevious xstart ystart hspeed vspeed direction speed friction gravity gravity_direction path_index path_position path_positionprevious path_speed path_scale path_orientation path_endaction object_index id solid persistent mask_index instance_count instance_id room_speed fps fps_real current_time current_year current_month current_day current_weekday current_hour current_minute current_second alarm timeline_index timeline_position timeline_speed timeline_running timeline_loop room room_first room_last room_width room_height room_caption room_persistent score lives health show_score show_lives show_health caption_score caption_lives caption_health event_type event_number event_object event_action application_surface gamemaker_pro gamemaker_registered gamemaker_version error_occurred error_last debug_mode keyboard_key keyboard_lastkey keyboard_lastchar keyboard_string mouse_x mouse_y mouse_button mouse_lastbutton cursor_sprite visible sprite_index sprite_width sprite_height sprite_xoffset sprite_yoffset image_number image_index image_speed depth image_xscale image_yscale image_angle image_alpha image_blend bbox_left bbox_right bbox_top bbox_bottom layer background_colour  background_showcolour background_color background_showcolor view_enabled view_current view_visible view_xview view_yview view_wview view_hview view_xport view_yport view_wport view_hport view_angle view_hborder view_vborder view_hspeed view_vspeed view_object view_surface_id view_camera game_id game_display_name game_project_name game_save_id working_directory temp_directory program_directory browser_width browser_height os_type os_device os_browser os_version display_aa async_load delta_time webgl_enabled event_data iap_data phy_rotation phy_position_x phy_position_y phy_angular_velocity phy_linear_velocity_x phy_linear_velocity_y phy_speed_x phy_speed_y phy_speed phy_angular_damping phy_linear_damping phy_bullet phy_fixed_rotation phy_active phy_mass phy_inertia phy_com_x phy_com_y phy_dynamic phy_kinematic phy_sleeping phy_collision_points phy_collision_x phy_collision_y phy_col_normal_x phy_col_normal_y phy_position_xprevious phy_position_yprevious"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]
-})})());
-hljs.registerLanguage("go",(()=>{"use strict";return e=>{const n={
-keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
-literal:"true false iota nil",
-built_in:"append cap close complex copy imag len make new panic print println real recover delete"
-};return{name:"Go",aliases:["golang"],keywords:n,illegal:"</",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{
-className:"number",variants:[{begin:e.C_NUMBER_RE+"[i]",relevance:1
-},e.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",
-end:"\\s*(\\{|$)",excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params",
-begin:/\(/,end:/\)/,keywords:n,illegal:/["']/}]}]}}})());
-hljs.registerLanguage("golo",(()=>{"use strict";return e=>({name:"Golo",
-keywords:{
-keyword:"println readln print import module function local return let var while for foreach times in case when match with break continue augment augmentation each find filter reduce if then else otherwise try catch finally raise throw orIfNull DynamicObject|10 DynamicVariable struct Observable map set vector list array",
-literal:"true false null"},
-contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"@[A-Za-z]+"}]})})());
-hljs.registerLanguage("gradle",(()=>{"use strict";return e=>({name:"Gradle",
-case_insensitive:!0,keywords:{
-keyword:"task project allprojects subprojects artifacts buildscript configurations dependencies repositories sourceSets description delete from into include exclude source classpath destinationDir includes options sourceCompatibility targetCompatibility group flatDir doLast doFirst flatten todir fromdir ant def abstract break case catch continue default do else extends final finally for if implements instanceof native new private protected public return static switch synchronized throw throws transient try volatile while strictfp package import false null super this true antlrtask checkstyle codenarc copy boolean byte char class double float int interface long short void compile runTime file fileTree abs any append asList asWritable call collect compareTo count div dump each eachByte eachFile eachLine every find findAll flatten getAt getErr getIn getOut getText grep immutable inject inspect intersect invokeMethods isCase join leftShift minus multiply newInputStream newOutputStream newPrintWriter newReader newWriter next plus pop power previous print println push putAt read readBytes readLines reverse reverseEach round size sort splitEachLine step subMap times toInteger toList tokenize upto waitForOrKill withPrintWriter withReader withStream withWriter withWriterAppend write writeLine"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.REGEXP_MODE]
-})})());
-hljs.registerLanguage("groovy",(()=>{"use strict";function e(e,n={}){
-return n.variants=e,n}return n=>{
-const a="[A-Za-z0-9_$]+",t=e([n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.COMMENT("/\\*\\*","\\*/",{
-relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",
-begin:"@[A-Za-z]+"}]})]),s={className:"regexp",begin:/~?\/[^\/\n]+\//,
-contains:[n.BACKSLASH_ESCAPE]
-},i=e([n.BINARY_NUMBER_MODE,n.C_NUMBER_MODE]),r=e([{begin:/"""/,end:/"""/},{
-begin:/'''/,end:/'''/},{begin:"\\$/",end:"/\\$",relevance:10
-},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE],{className:"string"});return{
-name:"Groovy",keywords:{built_in:"this super",literal:"true false null",
-keyword:"byte short char int long boolean float double void def as in assert trait abstract static volatile transient public private protected synchronized final class interface enum if else for while switch case break default continue throw throws try catch finally implements extends new import package return instanceof"
-},contains:[n.SHEBANG({binary:"groovy",relevance:10}),t,r,s,i,{
-className:"class",beginKeywords:"class interface trait enum",end:/\{/,
-illegal:":",contains:[{beginKeywords:"extends implements"
-},n.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"@[A-Za-z]+",relevance:0},{
-className:"attr",begin:a+"[ \t]*:",relevance:0},{begin:/\?/,end:/:/,relevance:0,
-contains:[t,r,s,i,"self"]},{className:"symbol",
-begin:"^[ \t]*"+(l=a+":",((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",l,")")),
-excludeBegin:!0,end:a+":",relevance:0}],illegal:/#|<\//};var l}})());
-hljs.registerLanguage("haml",(()=>{"use strict";return e=>({name:"HAML",
-case_insensitive:!0,contains:[{className:"meta",
-begin:"^!!!( (5|1\\.1|Strict|Frameset|Basic|Mobile|RDFa|XML\\b.*))?$",
-relevance:10},e.COMMENT("^\\s*(!=#|=#|-#|/).*$",!1,{relevance:0}),{
-begin:"^\\s*(-|=|!=)(?!#)",starts:{end:"\\n",subLanguage:"ruby"}},{
-className:"tag",begin:"^\\s*%",contains:[{className:"selector-tag",begin:"\\w+"
-},{className:"selector-id",begin:"#[\\w-]+"},{className:"selector-class",
-begin:"\\.[\\w-]+"},{begin:/\{\s*/,end:/\s*\}/,contains:[{begin:":\\w+\\s*=>",
-end:",\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",
-begin:":\\w+"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",relevance:0
-}]}]},{begin:"\\(\\s*",end:"\\s*\\)",excludeEnd:!0,contains:[{begin:"\\w+\\s*=",
-end:"\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",
-begin:"\\w+",relevance:0},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",
-relevance:0}]}]}]},{begin:"^\\s*[=~]\\s*"},{begin:/#\{/,starts:{end:/\}/,
-subLanguage:"ruby"}}]})})());
-hljs.registerLanguage("handlebars",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return a=>{const t={
-"builtin-name":["action","bindattr","collection","component","concat","debugger","each","each-in","get","hash","if","in","input","link-to","loc","log","lookup","mut","outlet","partial","query-params","render","template","textarea","unbound","unless","view","with","yield"]
-},s=/\[\]|\[[^\]]+\]/,i=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,r=((...n)=>"("+n.map((n=>e(n))).join("|")+")")(/""|"[^"]+"/,/''|'[^']+'/,s,i),l=n(n("(",/\.|\.\/|\//,")?"),r,(h=n(/(\.|\/)/,r),
-n("(",h,")*"))),c=n("(",s,"|",i,")(?==)"),o={begin:l,lexemes:/[\w.\/]+/
-},m=a.inherit(o,{keywords:{literal:["true","false","undefined","null"]}}),d={
-begin:/\(/,end:/\)/},g={className:"attr",begin:c,relevance:0,starts:{begin:/=/,
-end:/=/,starts:{
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,m,d]}}},b={
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{begin:/as\s+\|/,
-keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},g,m,d],returnEnd:!0
-},u=a.inherit(o,{className:"name",keywords:t,starts:a.inherit(b,{end:/\)/})})
-;var h;d.contains=[u];const N=a.inherit(o,{keywords:t,className:"name",
-starts:a.inherit(b,{end:/\}\}/})}),p=a.inherit(o,{keywords:t,className:"name"
-}),E=a.inherit(o,{className:"name",keywords:t,starts:a.inherit(b,{end:/\}\}/})})
-;return{name:"Handlebars",
-aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,
-subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,
-skip:!0},a.COMMENT(/\{\{!--/,/--\}\}/),a.COMMENT(/\{\{!/,/\}\}/),{
-className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[N],
-starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{
-className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[p]},{
-className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[N]},{
-className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{
-className:"template-tag",begin:/\{\{(?=else if)/,end:/\}\}/,keywords:"else if"
-},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[p]},{
-className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[E]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[E]}]}}})());
-hljs.registerLanguage("haskell",(()=>{"use strict";return e=>{const n={
-variants:[e.COMMENT("--","$"),e.COMMENT(/\{-/,/-\}/,{contains:["self"]})]},i={
-className:"meta",begin:/\{-#/,end:/#-\}/},a={className:"meta",begin:"^#",end:"$"
-},s={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},l={begin:"\\(",
-end:"\\)",illegal:'"',contains:[i,a,{className:"type",
-begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},e.inherit(e.TITLE_MODE,{
-begin:"[_a-z][\\w']*"}),n]};return{name:"Haskell",aliases:["hs"],
-keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",
-contains:[{beginKeywords:"module",end:"where",keywords:"module where",
-contains:[l,n],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",
-keywords:"import qualified as hiding",contains:[l,n],illegal:"\\W\\.|;"},{
-className:"class",begin:"^(\\s*)?(class|instance)\\b",end:"where",
-keywords:"class family instance where",contains:[s,l,n]},{className:"class",
-begin:"\\b(data|(new)?type)\\b",end:"$",
-keywords:"data family type newtype deriving",contains:[i,s,l,{begin:/\{/,
-end:/\}/,contains:l.contains},n]},{beginKeywords:"default",end:"$",
-contains:[s,l,n]},{beginKeywords:"infix infixl infixr",end:"$",
-contains:[e.C_NUMBER_MODE,n]},{begin:"\\bforeign\\b",end:"$",
-keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",
-contains:[s,e.QUOTE_STRING_MODE,n]},{className:"meta",
-begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"
-},i,a,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,s,e.inherit(e.TITLE_MODE,{
-begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}]}}})());
-hljs.registerLanguage("haxe",(()=>{"use strict";return e=>({name:"Haxe",
-aliases:["hx"],keywords:{
-keyword:"break case cast catch continue default do dynamic else enum extern for function here if import in inline never new override package private get set public return static super switch this throw trace try typedef untyped using var while Int Float String Bool Dynamic Void Array ",
-built_in:"trace this",literal:"true false null _"},contains:[{
-className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE,{
-className:"subst",begin:"\\$\\{",end:"\\}"},{className:"subst",begin:"\\$",
-end:/\W\}/}]
-},e.QUOTE_STRING_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"@:",end:"$"},{className:"meta",begin:"#",end:"$",
-keywords:{"meta-keyword":"if else elseif end error"}},{className:"type",
-begin:":[ \t]*",end:"[^A-Za-z0-9_ \t\\->]",excludeBegin:!0,excludeEnd:!0,
-relevance:0},{className:"type",begin:":[ \t]*",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"new *",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"class",beginKeywords:"enum",end:"\\{",
-contains:[e.TITLE_MODE]},{className:"class",beginKeywords:"abstract",
-end:"[\\{$]",contains:[{className:"type",begin:"\\(",end:"\\)",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"from +",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"to +",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},e.TITLE_MODE],keywords:{keyword:"abstract from to"}},{
-className:"class",begin:"\\b(class|interface) +",end:"[\\{$]",excludeEnd:!0,
-keywords:"class interface",contains:[{className:"keyword",
-begin:"\\b(extends|implements) +",keywords:"extends implements",contains:[{
-className:"type",begin:e.IDENT_RE,relevance:0}]},e.TITLE_MODE]},{
-className:"function",beginKeywords:"function",end:"\\(",excludeEnd:!0,
-illegal:"\\S",contains:[e.TITLE_MODE]}],illegal:/<\//})})());
-hljs.registerLanguage("hsp",(()=>{"use strict";return e=>({name:"HSP",
-case_insensitive:!0,keywords:{$pattern:/[\w._]+/,
-keyword:"goto gosub return break repeat loop continue wait await dim sdim foreach dimtype dup dupptr end stop newmod delmod mref run exgoto on mcall assert logmes newlab resume yield onexit onerror onkey onclick oncmd exist delete mkdir chdir dirlist bload bsave bcopy memfile if else poke wpoke lpoke getstr chdpm memexpand memcpy memset notesel noteadd notedel noteload notesave randomize noteunsel noteget split strrep setease button chgdisp exec dialog mmload mmplay mmstop mci pset pget syscolor mes print title pos circle cls font sysfont objsize picload color palcolor palette redraw width gsel gcopy gzoom gmode bmpsave hsvcolor getkey listbox chkbox combox input mesbox buffer screen bgscr mouse objsel groll line clrobj boxf objprm objmode stick grect grotate gsquare gradf objimage objskip objenable celload celdiv celput newcom querycom delcom cnvstow comres axobj winobj sendmsg comevent comevarg sarrayconv callfunc cnvwtos comevdisp libptr system hspstat hspver stat cnt err strsize looplev sublev iparam wparam lparam refstr refdval int rnd strlen length length2 length3 length4 vartype gettime peek wpeek lpeek varptr varuse noteinfo instr abs limit getease str strmid strf getpath strtrim sin cos tan atan sqrt double absf expf logf limitf powf geteasef mousex mousey mousew hwnd hinstance hdc ginfo objinfo dirinfo sysinfo thismod __hspver__ __hsp30__ __date__ __time__ __line__ __file__ _debug __hspdef__ and or xor not screen_normal screen_palette screen_hide screen_fixedsize screen_tool screen_frame gmode_gdi gmode_mem gmode_rgb0 gmode_alpha gmode_rgb0alpha gmode_add gmode_sub gmode_pixela ginfo_mx ginfo_my ginfo_act ginfo_sel ginfo_wx1 ginfo_wy1 ginfo_wx2 ginfo_wy2 ginfo_vx ginfo_vy ginfo_sizex ginfo_sizey ginfo_winx ginfo_winy ginfo_mesx ginfo_mesy ginfo_r ginfo_g ginfo_b ginfo_paluse ginfo_dispx ginfo_dispy ginfo_cx ginfo_cy ginfo_intid ginfo_newid ginfo_sx ginfo_sy objinfo_mode objinfo_bmscr objinfo_hwnd notemax notesize dir_cur dir_exe dir_win dir_sys dir_cmdline dir_desktop dir_mydoc dir_tv font_normal font_bold font_italic font_underline font_strikeout font_antialias objmode_normal objmode_guifont objmode_usefont gsquare_grad msgothic msmincho do until while wend for next _break _continue switch case default swbreak swend ddim ldim alloc m_pi rad2deg deg2rad ease_linear ease_quad_in ease_quad_out ease_quad_inout ease_cubic_in ease_cubic_out ease_cubic_inout ease_quartic_in ease_quartic_out ease_quartic_inout ease_bounce_in ease_bounce_out ease_bounce_inout ease_shake_in ease_shake_out ease_shake_inout ease_loop"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{
-className:"string",begin:/\{"/,end:/"\}/,contains:[e.BACKSLASH_ESCAPE]
-},e.COMMENT(";","$",{relevance:0}),{className:"meta",begin:"#",end:"$",
-keywords:{
-"meta-keyword":"addion cfunc cmd cmpopt comfunc const defcfunc deffunc define else endif enum epack func global if ifdef ifndef include modcfunc modfunc modinit modterm module pack packopt regcmd runtime undef usecom uselib"
-},contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"
-}),e.NUMBER_MODE,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]
-},{className:"symbol",begin:"^\\*(\\w+|@)"},e.NUMBER_MODE,e.C_NUMBER_MODE]})
-})());
-hljs.registerLanguage("htmlbars",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return a=>{const t=function(a){const t={
-"builtin-name":["action","bindattr","collection","component","concat","debugger","each","each-in","get","hash","if","in","input","link-to","loc","log","lookup","mut","outlet","partial","query-params","render","template","textarea","unbound","unless","view","with","yield"]
-},s=/\[\]|\[[^\]]+\]/,i=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,r=((...n)=>"("+n.map((n=>e(n))).join("|")+")")(/""|"[^"]+"/,/''|'[^']+'/,s,i),l=n(n("(",/\.|\.\/|\//,")?"),r,(c=n(/(\.|\/)/,r),
-n("(",c,")*")));var c;const o=n("(",s,"|",i,")(?==)"),m={begin:l,
-lexemes:/[\w.\/]+/},d=a.inherit(m,{keywords:{
-literal:["true","false","undefined","null"]}}),g={begin:/\(/,end:/\)/},b={
-className:"attr",begin:o,relevance:0,starts:{begin:/=/,end:/=/,starts:{
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,d,g]}}},u={
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{begin:/as\s+\|/,
-keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},b,d,g],returnEnd:!0
-},h=a.inherit(m,{className:"name",keywords:t,starts:a.inherit(u,{end:/\)/})})
-;g.contains=[h];const N=a.inherit(m,{keywords:t,className:"name",
-starts:a.inherit(u,{end:/\}\}/})}),p=a.inherit(m,{keywords:t,className:"name"
-}),E=a.inherit(m,{className:"name",keywords:t,starts:a.inherit(u,{end:/\}\}/})})
-;return{name:"Handlebars",
-aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,
-subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,
-skip:!0},a.COMMENT(/\{\{!--/,/--\}\}/),a.COMMENT(/\{\{!/,/\}\}/),{
-className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[N],
-starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{
-className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[p]},{
-className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[N]},{
-className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{
-className:"template-tag",begin:/\{\{(?=else if)/,end:/\}\}/,keywords:"else if"
-},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[p]},{
-className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[E]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[E]}]}}(a)
-;return t.name="HTMLbars",a.getLanguage("handlebars")&&(t.disableAutodetect=!0),
-t}})());
-hljs.registerLanguage("http",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a="HTTP/(2|1\\.[01])",s={className:"attribute",
-begin:e("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{
-className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}
-},t=[s,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{
-name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+a+" \\d{3})",
-end:/$/,contains:[{className:"meta",begin:a},{className:"number",
-begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}},{
-begin:"(?=^[A-Z]+ (.*?) "+a+"$)",end:/$/,contains:[{className:"string",
-begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:a},{
-className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}
-},n.inherit(s,{relevance:0})]}}})());
-hljs.registerLanguage("hy",(()=>{"use strict";return e=>{
-var a="a-zA-Z_\\-!.?+*=<>&#'",t="["+a+"]["+a+"0-9/;:]*",i={$pattern:t,
-"builtin-name":"!= % %= & &= * ** **= *= *map + += , --build-class-- --import-- -= . / // //= /= < << <<= <= = > >= >> >>= @ @= ^ ^= abs accumulate all and any ap-compose ap-dotimes ap-each ap-each-while ap-filter ap-first ap-if ap-last ap-map ap-map-when ap-pipe ap-reduce ap-reject apply as-> ascii assert assoc bin break butlast callable calling-module-name car case cdr chain chr coll? combinations compile compress cond cons cons? continue count curry cut cycle dec def default-method defclass defmacro defmacro-alias defmacro/g! defmain defmethod defmulti defn defn-alias defnc defnr defreader defseq del delattr delete-route dict-comp dir disassemble dispatch-reader-macro distinct divmod do doto drop drop-last drop-while empty? end-sequence eval eval-and-compile eval-when-compile even? every? except exec filter first flatten float? fn fnc fnr for for* format fraction genexpr gensym get getattr global globals group-by hasattr hash hex id identity if if* if-not if-python2 import in inc input instance? integer integer-char? integer? interleave interpose is is-coll is-cons is-em