Merge "Don't inject "@GerritInstanceName Provider<String>""
diff --git a/.bazelproject b/.bazelproject
index ad7b022..0f2ff90 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -16,7 +16,7 @@
 targets:
   //...:all
 
-java_language_level: 11
+java_language_level: 17
 
 workspace_type: java
 
diff --git a/.bazelrc b/.bazelrc
index 9662078..74427f3 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,6 +1,7 @@
 # TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel
 # https://issues.gerritcodereview.com/issues/303819949
 common --noenable_bzlmod
+common --incompatible_enable_proto_toolchain_resolution
 
 build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
@@ -32,25 +33,6 @@
 build:remote_bb --config=config_bb
 build:remote_bb --config=build_shared
 
-# Define configuration using remotejdk_11, executes using remotejdk_11 or local_jdk
-build:build_java11_shared --java_language_version=11
-build:build_java11_shared --java_runtime_version=remotejdk_11
-build:build_java11_shared --tool_java_language_version=11
-build:build_java11_shared --tool_java_runtime_version=remotejdk_11
-
-build:java11 --config=build_java11_shared
-
-# Builds and executes on Google GCP RBE using remotejdk_11
-build:remote11 --config=config_gcp
-build:remote11 --config=build_java11_shared
-
-# Define remote11 configuration alias
-build:remote11_gcp --config=remote11
-
-# Builds and executes on BuildBuddy RBE using remotejdk_11
-build:remote11_bb --config=config_bb
-build:remote11_bb --config=build_java11_shared
-
 # Builds using remotejdk_21, executes using remotejdk_21 or local_jdk
 build:build_java21_shared --java_language_version=21
 build:build_java21_shared --java_runtime_version=remotejdk_21
@@ -70,10 +52,6 @@
 build:remote21_bb --config=config_bb
 build:remote21_bb --config=build_java21_shared
 
-# Enable modern C++ features
-build --cxxopt=-std=c++17
-build --host_cxxopt=-std=c++17
-
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
 # This will be the new default behavior at some point (and the flag was flipped
diff --git a/.bazelversion b/.bazelversion
index 66ce77b..b26a34e 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-7.0.0
+7.2.1
diff --git a/.github/pull_request_termplate.md b/.github/pull_request_termplate.md
new file mode 100644
index 0000000..e4b3563
--- /dev/null
+++ b/.github/pull_request_termplate.md
@@ -0,0 +1,14 @@
+Thank you for contributing to Gerrit Code Review!
+
+- Gerrit uses [gerrit-review.googlesource.com](https://gerrit-review.googlesource.com)
+for code changes and review
+- The [gerrit repository on github](https://github.com/GerritCodeReview/gerrit)
+is a read-only mirror
+- Therefore **pull requests in this repository cannot be merged**.
+
+Find documentation how to contribute to Gerrit here
+- [Submitting Patches](../SUBMITTING_PATCHES)
+- [How to Contribute](https://gerrit-review.googlesource.com/Documentation/dev-community.html#how-to-contribute)
+- [Crafting Changes](https://gerrit-review.googlesource.com/Documentation/dev-crafting-changes.html)
+- [Developer Setup](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
+- [Gerrit Code Review Workflow](https://gerrit-review.googlesource.com/Documentation/intro-user.html#code-review)
diff --git a/.gitignore b/.gitignore
index 0bbcaba..bc489bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@
 /.classpath
 /.factorypath
 /.idea
+/.aswb
 /.ijwb
 /.metadata
 /.project
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 0000000..721f3c0
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1,97 @@
+Adrian Görler <adrian.goerler@sap.com>                                                      Adrian Goerler <adrian.goerler@sap.com>
+Ahaan Ugale <ahaanugale@gmail.com>                                                          <augale@codeaurora.org>
+Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@gs.com>
+Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@credit-suisse.com>
+Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex <alex.ryazantsev@gmail.com>
+Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
+Alice Kober-Sotzek <aliceks@google.com>                                                     <aliceks@google.com>
+Alexandre Philbert <alexandre.philbert@ericsson.com>                                        <alexandre.philbert@hotmail.com>
+Andrew Bonventre <andybons@chromium.org>                                                    <andybons@google.com>
+Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
+Ben Rohlfs <brohlfs@google.com>                                                             brohlfs <brohlfs@google.com>
+Brad Larson <bklarson@gmail.com>                                                            <brad.larson@garmin.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonyericsson.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
+Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
+Chad Horohoe <chorohoe@wikimedia.org>                                                       <chadh@wikimedia.org>
+Changcheng Xiao <xchangcheng@google.com>                                                    xchangcheng
+Cheng Ke <chengke.info@gmail.com>                                                           <chengke.info@gmail.com>
+Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
+Darrien Glasser <darrien@arista.com>                                                        darrien <darrien@arista.com>
+Dave Borowitz <dborowitz@google.com>                                                        <dborowitz@google.com>
+David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
+David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
+David Pursehouse <dpursehouse@collab.net>                                                   <david.pursehouse@sonymobile.com>
+Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Türkoglu <deniz@spotify.com>
+Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Turkoglu <deniz@spotify.com>
+Doug Kelly <dougk.ff7@gmail.com>                                                            <doug.kelly@garmin.com>
+Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@gmail.com>
+Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@sap.com>
+Edwin Kempin <ekempin@google.com>                                                           ekempin <ekempin@google.com>
+Eryk Szymanski <eryksz@gmail.com>                                                           <eryksz@google.com>
+Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik@gandaraj.com>
+Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik.luthander@sonyericsson.com>
+Gerrit Code Review <no-reply@gerritcodereview.com>                                          <noreply-gerritcodereview@google.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@axis.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonyericsson.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonymobile.com>
+Han-Wen Nienhuys <hanwen@google.com>                                                        <hanwen@google.com>
+Hector Oswaldo Caballero <hector.caballero@ericsson.com>                                    <hector.caballero@ericsson.com>
+Hugo Arès <hugo.ares@ericsson.com>                                                          Hugo Ares <hugo.ares@ericsson.com>
+Hugo Arès <hugo.ares@ericsson.com>                                                          <hugares@gmail.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <gemincia.programs@gmail.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <geminica.programs@gmail.com>
+James E. Blair <jeblair@redhat.com>                                                         <jeblair@hp.com>
+Jason Huntley <jhuntley@houghtonassociates.com>                                             jhuntley <jhuntley@houghtonassociates.com>
+Jiří Engelthaler <EngyCZ@gmail.com>                                                         <engycz@gmail.com>
+Joe Onorato <onoratoj@gmail.com>                                                            <joeo@android.com>
+Joel Dodge <dodgejoel@gmail.com>                                                            dodgejoel <dodgejoel@gmail.com>
+Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
+JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
+Kasper Nilsson <kaspern@google.com>                                                         <kaspern@google.com>
+Lawrence Dubé <ldube@audiokinetic.com>                                                      <ldube@audiokinetic.com>
+Lei Sun <lei.sun01@sap.com>                                                                 LeiSun <lei.sun01@sap.com>
+Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
+Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
+Magnus Bäck <magnus.back@axis.com>                                                          <baeck@google.com>
+Magnus Bäck <magnus.back@axis.com>                                                          <magnus.back@sonyericsson.com>
+Marco Miller <marco.miller@ericsson.com>                                                    <marco.mmiller@gmail.com>
+Mark Derricutt <mark.derricutt@smxemail.com>                                                <mark@talios.com>
+Martin Fick <mfick@codeaurora.org>                                                          <mogulguy10@gmail.com>
+Martin Fick <mfick@codeaurora.org>                                                          <mogulguy@yahoo.com>
+Martin Wallgren <martinwa@axis.com>                                                         <martin.wallgren@axis.com>
+Matthias Sohn <matthias.sohn@sap.com>                                                       <matthias.sohn@gmail.com>
+Maxime Guerreiro <maximeg@google.com>                                                       <maximeg@google.com>
+Michael Zhou <moz@google.com>                                                               <zhoumotongxue008@gmail.com>
+Monty Taylor <mordred@inaugust.com>                                                         <monty.taylor@gmail.com>
+Mônica Dionísio <monica.dionisio@sonyericsson.com>                                          monica.dionisio <monica.dionisio@sonyericsson.com>
+Nasser Grainawi <nasser@grainawi.org>                                                       <nasser@codeaurora.org>
+Nasser Grainawi <nasser@grainawi.org>                                                       <nasserg@quicinc.com>
+Orgad Shaneh <orgads@gmail.com>                                                             <orgad.shaneh@audiocodes.com>
+Paladox <thomasmulhall410@yahoo.com>                                                        <thomasmulhall410@yahoo.com>
+Patrick Hiesel <hiesel@google.com>                                                          <hiesel@hiesel-macbookpro2.roam.corp.google.com>
+Peter Jönsson <peter.joensson@gmail.com>                                                    Peter Jönsson <peter.joensson@gmail.com>
+Rafael Rabelo Silva <rafael.rabelosilva@sonyericsson.com>                                   rafael.rabelosilva <rafael.rabelosilva@sonyericsson.com>
+Réda Housni Alaoui <reda.housnialaoui@gmail.com>                                            <alaoui.rda@gmail.com>
+Richard Möhn <richard.moehn@posteo.de>                                                      <richard.moehn@fu-berlin.de>
+Sam Saccone <samccone@google.com>                                                           <samccone@gmail.com>
+Sam Saccone <samccone@google.com>                                                           <samccone@google.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <sasa.zivkov@sap.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Saša Živkov <zivkov@gmail.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <zivkov@gmail.com>
+Scott Dial <scott@scottdial.com>                                                            <geekmug@gmail.com>
+Shawn Pearce <sop@google.com>                                                               Shawn O. Pearce <sop@google.com>
+Sixin Li <sixin210@gmail.com>                                                               sixin li <sixin210@gmail.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@axis.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@sonymobile.com>
+Thomas Dräbing <thomas.draebing@sap.com>                                                    <thomas.draebing@sap.com>
+Tom Wang <twang10@gmail.com>                                                                Tom <twang10@gmail.com>
+Tomas Westling <thomas.westling@sonyericsson.com>                                           thomas.westling <thomas.westling@sonyericsson.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                <ulrik.sjolin@gmail.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@gmail.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>
+Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@sonyericsson.com>
+Viktar Donich <viktard@google.com>                                                          viktard
+Yuxuan 'fishy' Wang <fishywang@google.com>                                                  Yuxuan Wang <fishywang@google.com>
+Zalán Blénessy <zalanb@axis.com>                                                            Zalan Blenessy <zalanb@axis.com>
+飞 李 <lifei@7v1.net>                                                                       lifei <lifei@7v1.net>
diff --git a/.zuul.yaml b/.zuul.yaml
index d6dbc34..e0e92fa 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -7,6 +7,7 @@
       This adds required projects needed for all Gerrit-related builds
       (i.e., builds of Gerrit itself or plugins) on this branch.
     required-projects:
+      - java-prettify
       - jgit
 
 - job:
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 2e90cff..52b2b18 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -97,10 +97,11 @@
 groups are created on Gerrit site initialization and unique UUIDs are assigned
 to those groups. These UUIDs are different on different Gerrit sites.
 
-Gerrit comes with two predefined groups:
+Gerrit comes with three predefined groups:
 
-* Administrators
-* Service Users
+* link:#administrators[Administrators]
+* link:#service_users[Service Users]
+* link:#blocked_users[Blocked Users]
 
 
 [[administrators]]
@@ -138,6 +139,15 @@
 
 Before Gerrit 3.3, the 'Service Users' group was named 'Non-Interactive Users'.
 
+[[blocked_users]]
+=== Blocked Users
+
+This is a predefined group, created on Gerrit site initialization, for which
+the link:#category_read[Read] access right is globally blocked.
+
+link:#administrators[Administrators] can add spammers to this group in order to
+block them from accessing Gerrit so that they cannot post any further spam.
+
 == Account Groups
 
 Account groups contain a list of zero or more user account members,
@@ -834,11 +844,16 @@
 
 Users without this access right can still remove their own votes.
 
+Note, removing votes is generally disallowed for merged changes as this could
+remove approvals that were necessary for the submission and it's confusing to
+see a merged change which doesn't have the necessary approvals to fulfill the
+submit requirements.
+
 [[category_remove_reviewer]]
 === Remove Reviewer
 
 This category permits users to remove other users from the list of
-reviewers on a change.
+reviewers on a change, including their votes.
 
 Change owners can always remove reviewers who have given a zero or positive
 score (even without having the `Remove Reviewer` access right assigned).
@@ -849,6 +864,11 @@
 Users without this access right can only remove themselves from the
 reviewer list on a change.
 
+Note, removing reviewers with non-zero votes is generally disallowed for merged
+changes as this could remove approvals that were necessary for the submission
+and it's confusing to see a merged change which doesn't have the necessary
+approvals to fulfill the submit requirements.
+
 
 [[category_review_labels]]
 === Review Labels
@@ -898,6 +918,9 @@
 `on_behalf_of` field in link:rest-api-changes.html#submit-input[SubmitInput]
 when link:rest-api-changes.html#submit-change[submitting using the REST API].
 
+The user in the `on_behalf_of` field, does not need to have `Submit` permission
+themselves, however they should be able to `read` the changes being submitted.
+
 Note that this permission is named `submitAs` in the `project.config`
 file.
 
@@ -1541,7 +1564,12 @@
 
 Allow checking access rights for arbitrary (user, project) pairs,
 using the link:rest-api-projects.html#check-access[check.access]
-endpoint
+endpoint.
+
+In addition, when a request fails due to permission errors and the caller has
+this capability, ACL info is returned that contains information about the
+permissions rules that have been checked. This allows the user to understand
+which permissions rule caused request to be rejected.
 
 [[capability_viewAllAccounts]]
 === View All Accounts
diff --git a/Documentation/cmd-index-changes.txt b/Documentation/cmd-index-changes.txt
index 0ee7aab..1d4cbe34 100644
--- a/Documentation/cmd-index-changes.txt
+++ b/Documentation/cmd-index-changes.txt
@@ -16,8 +16,7 @@
 supported by the REST API.
 
 == ACCESS
-Caller must have the 'Maintain Server' capability, or be the owner of the change
-to be indexed.
+Caller must have the 'Maintain Server' capability.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3ddc3ee..532c93b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -535,6 +535,13 @@
 +
 By default, `false`.
 
+[[auth.cookieHttpOnly]]auth.cookieHttpOnly::
++
+Sets "httpOnly" flag of the authentication cookie. If `true`, cookie
+values can't be accessed by client side scripts.
++
+By default, `true`.
+
 [[auth.emailFormat]]auth.emailFormat::
 +
 Optional format string to construct user email addresses out of
@@ -1352,6 +1359,33 @@
 
 Default is 00:00 if the project_list cache warmer is enabled.
 
+[[cachePruning]]
+=== Section cachePruning
+
+[[cachePruning.pruneOnStartup]]cachePruning.pruneOnStartup::
++
+Whether to asynchronously prune all cache when starting Gerrit.
++
+Defaults to `true`.
+
+[[cachePruning.startTime]]cachePruning.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+cache pruning.
++
+Defaults to `01:00`.
+
+[[cachePruning.interval]]cachePruning.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+cache pruning.
++
+Defaults to `1d`.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
+
+
 [[capability]]
 === Section capability
 
@@ -1614,13 +1648,6 @@
 +
 Default is `true`.
 
-[[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
-+
-Show assignee field in changes table. If set to `false`, assignees will
-not be visible in changes table.
-+
-Default is `false`.
-
 [[change.strictLabels]]change.strictLabels::
 +
 Reject invalid label votes: invalid labels or invalid values. This
@@ -1699,7 +1726,7 @@
 With a configured 30 second delay a server with 4900 active users will
 typically need to dedicate 1 CPU to the update check.  4900 users
 divided by an average delay of 30 seconds is 163 requests arriving per
-second.  If requests are served at \~6 ms response time, 1 CPU is
+second.  If requests are served at ~6 ms response time, 1 CPU is
 necessary to keep up with the update request traffic.  On a smaller
 user base of 500 active users, the default 30 second delay is only 17
 requests per second and requires ~10% CPU.
@@ -1710,10 +1737,12 @@
 
 [[change.diff3ConflictView]]change.diff3ConflictView::
 +
-Use the diff3 formatter for merge commits with conflicts. With diff3 when
-the conflicts are shown in the "Auto Merge" view, the base section from the
-common parents will be shown as well.
+Use the diff3 formatter for merge, rebase and cherry-picking commits with conflicts.
++
+For merge commits with diff3 when the conflicts are shown in the "Auto Merge" view,
+the base section from the common parents will be shown as well.
 This setting takes effect when generating the automerge, which happens on upload.
+Also the setting takes effect when using rebase or cherry-picking in Gerrit Web UI.
 Changing the setting leaves existing changes unaffected.
 +
 Default is `false`.
@@ -2573,6 +2602,18 @@
 link:access-control.html#capability_queryLimit[queryLimit]
 which is defaulted to 500 entries.
 
+[[gerrit.projectStatePredicateEnabled]]
++
+Indicates whether the link:rest-api-projects.html[/projects/] REST API endpoint
+supports filtering projects by state. The value is exposed in
+link:rest-api-config.html[/config/server/info] REST API endpoint.
++
+Instances having a custom implementation of ProjectQueryBuilder might have
+disabled the `state` predicate in which case the setting should be set to
+`false`.
++
+Default: `true`.
+
 [[gerrit.primaryWeblinkName]]gerrit.primaryWeblinkName::
 +
 Name of the link:dev-plugins.html#links-to-external-tools[Weblink] that should
@@ -2721,6 +2762,13 @@
 +
 By default empty.
 
+[[gerrit.requireChangeForConfigUpdate]]gerrit.requireChangeForConfigUpdate::
++
+If true, all attempts to update a project config directly using any REST API are rejected.
+Instead, users should always use APIs which create a config change (for review).
++
+By default `false`.
+
 [[gerrit.serverId]]gerrit.serverId::
 +
 Used by NoteDb to, amongst other things, identify author identities from
@@ -3488,6 +3536,21 @@
 Excluded projects can later be reindexed by for example using the
 link:cmd-index-changes-in-project.html[index changes in project command].
 
+[[index.reuseExistingDocuments]]index.reuseExistingDocuments::
++
+Whether to reuse index documents that already exist during reindexing.
++
+Currently, only supported by the changes index.
++
+This feature is useful, if the Gerrit server has to be restarted
+during an ongoing index online upgrade, since this would cause
+a complete reindexing otherwise that might take an extensive time.
++
+Each existing document in the index will be checked for staleness
+and reindexed if found to be stale.
++
+Defaults to false.
+
 [[index.paginationType]]index.paginationType::
 +
 The pagination type to use when index queries are repeated to
@@ -3621,9 +3684,77 @@
 +
 Defaults to `false`.
 
+[[scheduledIndexer]]
+=== Section scheduledIndexer
+
+This section configures periodic indexing. Periodic indexing can be run
+on both primaries and replicas.
+
+Scheduled indexer is currently supported for projects and groups indexes.
+
+The default values for the following options may be different for the groups
+index on replicas. Check the
+link:#scheduledIndexer.groups[scheduledIndexer.groups] section for more info.
+
+[[scheduledIndexer.name.runOnStartup]]scheduledIndexer.<name>.runOnStartup::
++
+Whether the scheduled indexer should run once immediately on startup.
+If set to `true` the server startup is blocked until indexing finishes.
++
+Defaults to `false`.
+
+[[scheduledIndexer.name.enabled]]scheduledIndexer.<name>.enabled::
++
+Whether the periodic scheduled indexer is enabled.
++
+Defaults to `false`.
+
+[[scheduledIndexer.name.startTime]]scheduledIndexer.<name>.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+the scheduled indexer.
++
+Defaults to `00:00`.
+
+[[scheduledIndexer.name.interval]]scheduledIndexer.<name>.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+the scheduled indexer.
++
+Defaults to `5m`.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
+
+
+[[scheduledIndexer.groups]]
+==== Subsection scheduledIndexer.groups
+
+Periodic groups reindexing will be scheduled by default on replicas if there is
+no explicit `scheduledIndexer.groups` configuration.
+
+Replication to replicas happens on Git level so that Gerrit is not aware
+of incoming replication events. But replicas need an updated group index
+to resolve memberships of users for ACL validation. To keep the group
+index in replicas up-to-date the Gerrit replica periodically scans the
+group refs in the All-Users repository to reindex groups if they are
+stale.
+
+[[scheduledIndexer.groups.enabled]]scheduledIndexer.groups.enabled::
++
+Whether the scheduled indexer is enabled. If the scheduled indexer is disabled
+you may need to implement other means to keep the groups index on replicas
+up-to-date.
+Whether the periodic scheduled indexer is enabled.
++
+Defaults to `true` for replicas, `false` for primaries.
+
+
 [[index.scheduledIndexer]]
 ==== Subsection index.scheduledIndexer
 
+*(DEPRECATED)* Use the link:#scheduledIndexer[scheduledIndexer section] instead.
+
 This section configures periodic indexing. Periodic indexing is
 intended to run only on replicas and only updates the group index.
 Replication to replicas happens on Git level so that Gerrit is not aware
@@ -4254,6 +4385,12 @@
 must have the DWORD value `allowtgtsessionkey` set to 1 and the account must not
 have local administrator privileges.
 
+**NOTE**: Windows is not recommended as a server-side platform for
+running Gerrit Code Review, because of the lack of adoption from the Gerrit Community,
+incomplete functional validation and lack of security testing. Gerrit on
+Windows Server is not actively supported even though it may still be
+fully or partially functioning as expected.
+
 [[ldap.useConnectionPooling]]ldap.useConnectionPooling::
 +
 _(Optional)_ Enable the LDAP connection pooling or not.
@@ -4348,6 +4485,33 @@
 +
 Defaults to `true`.
 
+[[log.daysToKeep]]log.timeToKeep::
++
+Time that logs should be kept until they are being deleted. Values should use common
+suffixes to express their setting:
++
+* d, day, days
+* w, week, weeks (`1 week` is treated as `7 days`)
+* mon, month, months (`1 month` is treated as `30 days`)
+* y, year, years (`1 year` is treated as `365 days`)
++
+The minimum granularity is days. Using a smaller time unit will result in deletion of
+all old logs, as if `0d` would have been configured.
++
+Actively used logs will never be deleted. Thus, this feature only works in combination
+with enabled link:#log.rotate[log.rotate]. Log deletion happens at server startup and
+then daily at 11pm (in the server's local time zone).
++
+Depending on the filesystem the following file times will be used, in order of priority:
++
+* Time of file creation
+* Time when the file was last modified
+* Date added to the filename as part of log file rotation. Time will be set to `00:00:00Z`.
++
+If none of the above is available, the log file won't be deleted.
++
+Defaults to `-1`, i.e. being disabled.
+
 [[metrics]]
 === Section metrics
 
@@ -4579,6 +4743,37 @@
 This config can be used when gerrit migrates from a deprecated plugin to the new one. The new plugin
 can (temporary) accept push options of the old plugin without registering such options.
 
+[[plugins.loadPriority]]plugins.loadPriority::
++
+List of `pluginName`s required to have a specific loading order during Gerrit startup.
++
+Each entry should contain a plugin name defined in the `MANIFEST.MF` under
+`Gerrit-PluginName` or a plugin JAR file name. During the Gerrit startup
+the `loadPriority` will influence the loading sequence of the JAR plugins.
++
+NOTE: Non-JAR plugins (e.g. Scripting, PolyGerrit plugins, ApiModule) are not
+influenced by this setting.
++
+Gerrit will always load plugins defining `Gerrit-ApiModule` in their
+`MANIFEST.MF` first. Then will load other plugins according to:
+
+ * the order of `plugins.loadPriority` in `gerrit.config`,
+ * the natural order of plugin JAR file names in `plugins/` directory.
++
+Example:
+Assuming we have three plugins: `a-plugin.jar`, `b-plugin.jar`, `c-plugin.jar`
+deployed to `plugins/` directory. By default Gerrit will load them in the same order
+as they are listed above, as that follows the _natural storing order_. Now assume
+the below configuration
+
+----
+[plugins]
+  loadPriority = c-plugin
+  loadPriority = a-plugin
+----
+
+Gerrit will load `c-plugin` first, followed-up by `a-plugin` and `b-plugin` last.
+
 [[receive]]
 === Section receive
 
@@ -4756,7 +4951,7 @@
 'min', etc.).
 +
 After the timeout is exceeded the task processing the receive gets a
-cancellation signal that allows the tast to finish gracefully.
+cancellation signal that allows the task to finish gracefully.
 link:#receive.cancellationTimeout[receive.cancellationTimeout]
 defines how much time the task has to react to the cancellation signal
 before it is focefully cancelled.
@@ -5009,6 +5204,22 @@
 +
 By default, 25 which means that formatting happens in the caller thread.
 
+[[performance]]
+=== Section performance
+
+[[performance.metric]]
+==== Subsection performance.metric
+
+Section to control for which operations latency and counts should be recorded
+in the link:metrics.html#performance[performance metrics].
+
+[[performance.metric.operation]]performance.metric.operation::
++
+Name of a Gerrit operation for which latency and counts should be recorded in
+the link:metrics.html#performance[performance metrics].
++
+The operation name must match the operation name that is used with TraceTimer.
+
 [[receiveemail]]
 === Section receiveemail
 
@@ -5297,7 +5508,6 @@
 +
 Defaults to an empty list, meaning no additional TLDs are allowed.
 
-
 [[sendemail.addInstanceNameInSubject]]sendemail.addInstanceNameInSubject::
 +
 When set to `true`, Gerrit will add its short name to the email subject, allowing recipients to quickly identify
@@ -5307,6 +5517,15 @@
 +
 Defaults to `false`.
 
+[[sendemail.includeThreadIndexHeader]]sendemail.includeThreadIndexHeader::
++
+When set to `true`, Gerrit will add the `Thread-Index` header to change emails
+the same as it does with the `References` header. The `Thread-Index` header is
+used by Microsoft email clients, so it's recommended to keep this `true` when
+users read emails from Gerrit using those clients.
++
+Defaults to `true`.
+
 
 [[site]]
 === Section site
@@ -5777,9 +5996,15 @@
 
 There can be multiple `tracing.<trace-id>` subsections to configure
 automatic tracing of requests. To be traced a request must match all
-conditions of one `tracing.<trace-id>` subsection. The subsection name
-is used as trace ID. Using this trace ID administrators can find
-matching log entries.
+conditions of one `tracing.<trace-id>` subsection. A `tracing.<trace-id>`
+subsection must specify at least one of `requestUriPattern`, `account` and
+`projectPattern`, or otherwise it is ignored. The subsection name is used as
+trace ID. Using this trace ID administrators can find matching log entries.
+
+[WARNING] Tracing requests can make them more expensive. To avoid performance
+issues trace configs should match as little requests as possible and only be
+added temporarily. Enabling tracing for too many requests can severely impact
+the performance and the availability of the server.
 
 [[tracing.traceid.requestType]]tracing.<trace-id>.requestType::
 +
@@ -6061,7 +6286,7 @@
 account deactivations.
 
 Note that the task will only be scheduled if the
-link:#autoUpdateAccountActiveStatus[auth.autoUpdateAccountActiveStatus]
+link:#auth.autoUpdateAccountActiveStatus[auth.autoUpdateAccountActiveStatus]
 is set to `true`.
 
 link:#schedule-configuration-examples[Schedule examples] can be found
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index 00e33a3..1a8824a 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -211,6 +211,12 @@
 
 copy the contents of lib into `msysgit/lib/perl5/5.8.8` and overwrite existing files.
 
+**NOTE**: Windows is not recommended as a server-side platform for
+running Gerrit Code Review, because of the lack of adoption from the Gerrit Community,
+incomplete functional validation and lack of security testing. Gerrit on
+Windows Server is not actively supported even though it may still be
+fully or partially functioning as expected.
+
 ==== Enable Gitweb Integration
 
 To enable the external gitweb integration, set
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 29d1b85..b7493a3 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -133,7 +133,7 @@
 
 ----
   [label "Verified"]
-      function = MaxWithBlock
+      function = NoBlock
       value = -1 Fails
       value = 0 No score
       value = +1 Verified
@@ -159,6 +159,23 @@
 +
 *Any +1 enables submit.*
 
+Set the function to "NoBlock" to enable configuring submit-requirements.
+All other possible label function values are deprecated. The default is still
+"MaxWithBlock" which doesn't allow using the more flexible submit-requirements.
+
+Add a submit-requirement for the "Verified" label to define which
+conditions are required to make the change submittable:
+
+----
+  [submit-requirement "Verified"]
+    submittableIf = label:Verified=MAX AND -label:Verified=MIN
+    applicableIf = -branch:refs/meta/config
+----
+
+See the
+link:config-submit-requirements.html#examples[submit-requirements
+documentation] for more details.
+
 For a change to be submittable, the change must have a `+1 Verified`
 in this label, and no `-1 Fails`.  Thus, `-1 Fails` can block a submit,
 while `+1 Verified` enables a submit.
@@ -339,7 +356,7 @@
 Gerrit currently supports the following predicates:
 
 [[changekind]]
-==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE}
+==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE,TRIVIAL_REBASE_WITH_MESSAGE_UPDATE}
 
 Matches if the diff between two patch sets was of a certain change kind:
 
@@ -410,6 +427,10 @@
 For the pre-installed Code-Review label this predicate is used by
 default.
 
+* [[trivial_rebase_with_message_update]]`TRIVIAL_REBASE_WITH_MESSAGE_UPDATE`:
++
+Same as TRIVIAL_REBASE, but commit message can be different.
+
 * [[rework]]`REWORK`:
 +
 Matches all kind of change kinds because any other change kind
@@ -564,12 +585,25 @@
 
 ----
   [label "Copyright-Check"]
-      function = MaxWithBlock
+      function = NoBlock
       value = -1 Do not have copyright
       value = 0 No score
       value = +1 Copyright clear
 ----
 
+Add a submit-requirement for the "Copyright-Check" label to define which
+score is required to make the change submittable:
+
+----
+  [submit-requirement "Copyright-Check"]
+    submittableIf = label:Copyright-Check=MAX AND -label:Copyright-Check=MIN
+    applicableIf = -branch:refs/meta/config
+----
+
+See the
+link:config-submit-requirements.html#examples[submit-requirements
+documentation] for more details.
+
 The new column will appear at the end of the table, and `-1 Do not have
 copyright` will block submit, while `+1 Copyright clear` is required to
 enable submit.
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 4f11ca8..1ec5208 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -170,6 +170,10 @@
 +
 A String containing the messageClass.
 
+$messageClassDisplay::
++
+A String containing the messageClass display text.
+
 === Change Emails
 
 Change related emails have the following template data available to them, in
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
index 04309e5..ef99d80 100644
--- a/Documentation/config-robot-comments.txt
+++ b/Documentation/config-robot-comments.txt
@@ -1,5 +1,9 @@
 = Gerrit Code Review - Robot Comments
 
+[NOTE]
+Robot Comments are deprecated in favour of link:pg-plugin-checks-api.html[Checks API] and human
+comments.
+
 Gerrit has special support for inline comments that are generated by
 automated third-party systems, so called "robot comments". For example
 robot comments can be used to represent the results of code analyzers.
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 5ab1add..560c77f 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -473,6 +473,38 @@
 +
 `distinctvoters:[Code-Review,Trust,API-Review],count>2`
 
+[[operator_label_with_users_arg]]
+label:'<label><operator><value>,users=human_reviewers'::
++
+Extension of the link:user-search.html#labels[label] predicate that
+allows matching changes that have a matching vote from all human
+reviewers. Votes from service users (members of the
+link:access-control.html#service_users[Service Users] group) and the
+change owner are ignored.
++
+If link:config-project-config.html#reviewer.enableByEmail[reviewers by
+email] are present then "user=all_reviewers" doesn't match if the
+expected value is other than 0. Reviewers by email are reviewers that
+don't have a Gerrit account.  Without Gerrit account they cannot vote
+on the change, which means changes that have any such reviewers never
+match when a vote from all reviewers is expected.
++
+If a change has no human reviewers, this operator doesn't match
+(because a human review is required but no human reviewer is present).
++
+Examples:
+`label:Code-Review=MAX,users=human_reviewers`
++
+`label:Code-Review>=1,users=human_reviewers`
++
+The 'users' arg cannot be combined with other arguments ('count',
+'user', 'group').
++
+'label:Code-Review=MAX,users=human_reviewers' can be used to
+implement "Want-Code-Review-From-All" functionaly, see
+link#require-code-review-approvals-from-all-human-reviewers-example[examples
+below].
+
 [[operator_is_true]]
 is:true::
 +
@@ -557,7 +589,7 @@
 == Examples
 
 [[code-review-example]]
-=== Code-Review Example
+=== Require Code-Review approval from a non-uploader
 
 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:
@@ -571,7 +603,7 @@
 ----
 
 [[exempt-branch-example]]
-=== Exempt a branch Example
+=== Exempt a branch
 
 We could exempt a submit requirement from certain branches. For example,
 project owners might want to skip the 'Code-Style' requirement from the
@@ -602,7 +634,7 @@
 ----
 
 [[require-footer-example]]
-=== Require a footer Example
+=== Require a footer
 
 It's possible to use a submit requirement to require a footer to be present in
 the commit message.
@@ -614,6 +646,59 @@
   submittableIf = hasfooter:\"Bug\"
 ----
 
+[[require-code-review-approvals-from-all-human-reviewers-example]]
+=== Require Code-Review approvals from all human reviewers
+
+The following submit requirement requires a 'Code-Review' approval
+('Code-Review+1' or 'Code-Review+2') from all human reviewers of the
+change. Votes from service users (members of the
+link:access-control.html#service_users[Service Users] group) and the
+change owner are ignored.
+
+The 'applicableIf' condition makes this submit requirement show up in
+the UI only if it is not satisfied (to keep the submit requirement
+showing when it is satisfied omit the 'applicableIf' condition).
+
+If a change has no human reviewers, this submit requirement is
+unsatisfied (because a human review is required but no human reviewer
+is present).
+
+----
+[submit-requirement "Want-Code-Review-From-All"]
+  description = A 'Code-Review' vote is required from all human \
+                reviewers (service users that are reviewers are \
+                ignored).
+  applicableIf = -label:Code-Review>=1,users=human_reviewers
+  submittableIf = label:Code-Review>=1,users=human_reviewers
+----
+
+It is possible to configure the 'Want-Code-Review-From-All' submit
+requirement so that it only applies when a 'Want-Code-Review: all'
+footer is present in the commit message. This way users can enable
+this submit requirement on demand by including this footer into their
+commit messages.
+
+The 'applicableIf' condition checks for the 'Want-Code-Review: all'
+footer and makes this submit requirement show up in the UI only if it
+is not satisfied (to keep the submit requirement showing when it is
+satisfied omit the '-label:Code-Review>=1,users=human_reviewers'
+predicate from the 'applicableIf' condition).
+
+Note, the footer key cannot contain underscores (e.g. using
+'Want_Code_Review: all' as the footer does not work).
+
+----
+[submit-requirement "Want-Code-Review-From-All"]
+  description = A 'Code-Review' vote is required from all human \
+                reviewers (service users that are reviewers are \
+                ignored).
+  applicableIf = footer:\"Want-Code-Review: all\" -label:Code-Review>=1,users=human_reviewers
+  submittableIf = label:Code-Review>=1,users=human_reviewers
+----
+
+For more information about the "users=human_reviewers" arg see
+link:#operator_label_with_users_arg[above].
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 4c224b5..5446c66 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -17,12 +17,12 @@
 
 To build Gerrit from source, you need:
 
-* A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 11 or Java 17
+* A Linux or macOS system (Windows is not supported)
+* A JDK for Java 17 or Java 21
 * 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`)
-* link:https://docs.bazel.build/versions/master/install.html[Bazel,role=external,window=_blank] -launched with
+* Yarn (`npm install -g yarn`)
+* link:https://bazel.build/install[Bazel,role=external,window=_blank] -launched with
 link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank]
 * Maven
 * zip, unzip
@@ -40,7 +40,7 @@
 worry about which version of Bazel you need.
 
 Bazelisk can be installed in different ways:
-link:https://docs.bazel.build/install-bazelisk.html[Bazelisk Installation,role=external,window=_blank].
+link:https://bazel.build/install/bazelisk[Bazelisk Installation,role=external,window=_blank].
 To execute the correct version of Bazel using Bazelisk you simply replace
 the `bazel` command with `bazelisk`.
 
@@ -54,13 +54,13 @@
 
 `java -version`
 
-[[java-11]]
-==== Java 11 support
+[[java-21]]
+==== Java 21 support
 
-To build Gerrit with Java 11 language level, run:
+To build Gerrit with Java 21 language level, run:
 
 ```
-  $ bazelisk build --config=java11 :release
+  $ bazelisk build --config=java21 :release
 ```
 
 [[java-17]]
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 6150c20..fcc457a 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -119,7 +119,7 @@
 
 The HTTPS access requires proper username and password; this can be obtained
 by clicking the 'Obtain Password' link on the
-link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
+link:https://gerrit-review.googlesource.com/settings/#HTTPCredentials[HTTP
 Password tab of the user settings page,role=external,window=_blank].
 
 Alternately, you may use the
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index bcc96b4..364dc9b 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -144,6 +144,40 @@
 This enables plugins to influence other plugins by customizing or extending the
 their behaviour.
 
+When a plugin wants to expose an API that *must* not be further overridden by
+other plugins, it could use the additional annotation `@DynamicItem.Final` which
+also gives the option to further limit the name of the plugin that is designated
+to bind the only implementation available.
+
+For example, a `plugin B` may declare an API as `@DynamicItem.Final` which is then
+bound in its `ApiModule`.
+
+```
+ @DynamicItem.Final(implementedByPlugin = "plugin-b-impl")
+ public interface PluginAPI {}
+
+ public class PluginApiModule extends AbstractModule {
+   @Override
+   protected void configure() {
+      DynamicItem.itemOf(binder(), PluginAPI.class);
+   }
+ }
+```
+
+The above definition of the `PluginApi` would be allowed to bound only by
+the `plugin-b-impl` which would associate its implementation class.
+
+```
+public class PluginImpl implements PluginAPI {}
+
+ public class PluginImplModule extends AbstractModule {
+   @Override
+   protected void configure() {
+      DynamicItem.bind(binder(), PluginAPI.class).to(PluginImpl.class);
+   }
+ }
+```
+
 *Gotchas and Limitations*:
 
 - A `plugin A` depending on a `plugin B` (declaring a `Gerrit-ApiModule`),
@@ -623,6 +657,105 @@
     .to(MyListener.class);
 ----
 
+Blocking inside onStop() is a good choice for QOS limits which are
+attempting to restrict total usage of resources as might be done to
+to prevent a server overload. In these cases, when a server's resources
+are being exhausted, it is important to throttle all `Tasks`, and blocking
+the current thread from being used by any Task makes sense. However,
+Task parking (see below) is more appropriate if it desirable to limit a
+specific resource usage in favor of other resources, such as when
+prioritization or fairness policies are desired.
+
+[[taskParker]]
+== TaskParkers
+
+It is possible to park `com.google.gerrit.server.git.WorkQueue$Task`s
+before they run without depriving other `Tasks` of a thread. Parking is
+particularly useful for (de-)prioritizing certain `Tasks` based on resource
+quotas without blocking `Tasks` not using those resources. For example,
+when there is a desire to limit how many commands a single user can run
+concurrently it is typically also desirable to not limit the total amount
+of concurrently running commands to the same limit. The Task parking
+mechanism is useful for such Task limiting scenarios.
+
+The `TaskParker` interface works well with a Semaphore's `tryAcquire()`
+method even when the Semaphore is unavailable. However, blocking should
+not be done with a `TaskParker` and if it is desired, such as when using a
+Semaphore's `acquire()` method, use a `TaskListener` interface instead.
+
+To make use of Task parking, implement a
+`com.google.gerrit.server.git.WorkQueue$TaskParker` and register the
+TaskParker (as a TaskListener) from a plugin like this:
+
+[source,java]
+----
+  public class MyParker implements TaskParker {
+    Semaphore semaphore = new Semaphore(3);
+
+    @Override
+    public boolean isReadyToStart(Task<?> task) {
+      try {
+        return semaphore.tryAcquire();
+      } catch (InterruptedException e) {
+        return false;
+      }
+    }
+
+    @Override
+    public void onNotReadyToStart(Task<?> task) {
+      semaphore.release();
+    }
+
+    @Override
+    public void onStart(Task<?> task) {}
+
+    @Override
+    public void onStop(Task<?> task) {
+      semaphore.release();
+    }
+  }
+
+  bind(TaskListener.class)
+      .annotatedWith(Exports.named("MyParker"))
+      .to(MyParker.class);
+----
+
+Before running a Task, the executor will query each `TaskParker` to see
+if the Task may be run by calling `isReadyToStart()`. If any `TaskParker`
+returns `false` from `isReadyToStart()`, then the Task will get parked
+and the executor will wait until another Task completes before
+attempting to run a parked task again.
+
+Since parked `Tasks` are not actually running and consuming resources,
+they generally should also not be contributing towards those resource
+quotas which caused the task to be parked. For this reason, once it is
+determined that a Task will be parked, the executor will call
+`onNotReadyToStart()` on every `TaskParker` that previously returned `true`
+from `isReadyToStart()`. This allows those TaskParkers to reduce their
+resource usage counts which they bumped up in `isReadyToStart()` with
+the expectation that the Task may run. Since the Task is not running and
+the resource is not being used, reducing the resource usage count allows
+other `Tasks` needing that resource to run while the Task is parked.
+
+Once a running Task completes, the executor will attempt to run
+parked `Tasks` (in the order in which they were parked) by again calling
+`isReadyToStart()` on the TaskParkers, even the TaskParkers which
+previously returned a `true` before the Task was parked. This is
+necessary because although a Task may not have exceeded a specific
+resource limit before it was parked, another Task may since have been
+allowed to run and its usage of that resource may now cause the parked
+task under evaluation to need to be throttled and parked again.
+
+Note, the reason that it is important to not block inside the
+`isReadyToStart()` method is to avoid delaying the executor from calling
+`onNotReadyToStart()` on other TaskParkers holding resources, as this
+would prevent them from freeing those resources. Also, just as it is
+important to later release any resources acquired within
+`isReadyToStart()` in `onStop()`, it is even more important to release
+those resources in `onNotReadyToStart()` since `isReadyToStart()` may
+be called many times per `TaskParker`, but `onStop()` will only ever be
+be called once.
+
 [[change-message-modifier]]
 == Change Message Modifier
 
@@ -2248,7 +2381,7 @@
 e.g. a plugin can provide a list of servers on which the change was
 deployed.
 
-Plugins can filter the branches and tags that are inlcuded by implementing
+Plugins can filter the branches and tags that are included by implementing
 `com.google.gerrit.server.change.FilterIncludedIn`.
 
 [source, java]
@@ -2281,7 +2414,6 @@
 
 [source, java]
 ----
-import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.change.FilterIncludedIn;
 import com.google.inject.AbstractModule;
@@ -2784,6 +2916,50 @@
 }
 ----
 
+[[account-state-provider]]
+== Account State Provider
+
+Gerrit provides an extension point that enables plugins to supply additional
+data for account states which are returned from the
+link:rest-api-accounts.html#get-state[Get Account State] REST endpoint (see
+`metadata` field in
+link:rest-api-accounts.html#account-state-info[AccountStateInfo]).
+
+[source, java]
+----
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.common.MetadataInfo;
+import com.google.gerrit.server.account.AccountStateProvider;
+
+public class MyPlugin implements AccountStateProvider {
+  public ImmutableList<MetadataInfo> getMetadata(Account.Id accountId) {
+    // Implement your logic here
+  }
+}
+----
+
+[[server-state-provider]]
+== Server State Provider
+
+Gerrit provides an extension point that enables plugins to supply additional
+data for server states which are returned from the
+link:rest-api-config.html#get-info[Get Server State] REST endpoint (see
+`metadata` field in link:rest-api-config.html#server-info[ServerInfo]).
+
+[source, java]
+----
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.common.MetadataInfo;
+import com.google.gerrit.server.ServerStateProvider;
+
+public class MyPlugin implements ServerStateProvider {
+  public ImmutableList<MetadataInfo> getMetadata() {
+    // Implement your logic here
+  }
+}
+----
+
 [[ssh-command-creation-interception]]
 == SSH Command Creation Interception
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index c2a1f86..2e87150 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -385,7 +385,7 @@
 == 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
+escalate this by writing an email to Chris Poucet: poucet@google.com
 
 [[deprecating-features]]
 == Deprecating features
diff --git a/Documentation/install.txt b/Documentation/install.txt
index 6e1a9bd..1342818 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -6,9 +6,7 @@
 
 To run the Gerrit service, the following requirement must be met on the host:
 
-* JRE, versions 1.8 or 11 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download,role=external,window=_blank]
-+
-Gerrit is not yet compatible with Java 13 or newer at this time.
+* JRE, versions 17 or 21 https://www.oracle.com/java/technologies/downloads/?er=221886[Download,role=external,window=_blank]
 
 [[download]]
 == Download Gerrit
@@ -180,6 +178,12 @@
         --StopClass=com.google.gerrit.launcher.GerritLauncher --StopMethod=daemonStop
 ====
 
+**NOTE**: Windows is not recommended as a server-side platform for
+running Gerrit Code Review, because of the lack of adoption from the Gerrit Community,
+incomplete functional validation and lack of security testing. Gerrit on
+Windows Server is not actively supported even though it may still be
+fully or partially functioning as expected.
+
 [[customize]]
 == Site Customization
 
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 15f27db..f4c5504 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -285,10 +285,6 @@
 more restrictive policy if you are facing issues with the build and
 test stability of the destination branches.
 
-It is also possible to define the submit type dynamically via
-link:#prolog-submit-type[Prolog]. This way you can use different submit
-types for different branches.
-
 Please note that there are other submit types available; they are
 described in the link:config-project-config.html#submit-type[Submit Type]
 section.
@@ -307,13 +303,11 @@
 approval from a special IP-team, it can define an `IP-Review` label
 and grant permissions to the IP-team to vote on this label.
 
-The behavior of a label can be controlled by its
-link:config-labels.html#label_function[function], e.g. it can be
-configured whether a max positive voting on the label is required for
-submit or if the voting on the label is optional.
-
-By using a custom link:#submit-rules[submit rule] it can be controlled
-per change whether a label is required for submit or not.
+The behavior of a label can be controlled by
+link:config-submit-requirements.html#labels[submit requirements], e.g. it can
+be configured whether a max positive voting on the label is required for
+submit or if only votes from certain users count or if the voting on
+the label is optional.
 
 A useful feature on labels is the possibility to automatically copy
 scores forward to new patch sets if it was a
@@ -321,45 +315,25 @@
 link:config-labels.html#no_code_change[there was no code change] (e.g.
 only the commit message was edited).
 
-[[submit-rules]]
-== Submit Rules
+[[submit-requirements]]
+== Submit requirements
 
-A link:prolog-cookbook.html#SubmitRule[submit rule] in Gerrit is logic
-that defines when a change is submittable. By default, a change is
-submittable when it gets at least one highest vote on each label and
-has no lowest vote (aka veto vote) on any label.
+Submit requirements is a powerful and lightweight way of configuring, per
+project, what is required for a change to be submittable.
+It replaces legacy ways of configuring submittability (such as prolog rules and
+label functions).
 
-The submit rules in Gerrit are implemented in link:prolog-cookbook.html[
-Prolog] and projects that need more flexibility can define their own
-submit rules to decide when a change should be submittable. A good
-link:prolog-cookbook.html#NonAuthorCodeReview[example] from the Prolog
-cookbook shows how to allow submit only if a change has a
-`Code-Review+2` vote from a person that is not the change author. This
-way a four-eyes principle for the reviews can be enforced.
+Read more in the
+link:config-submit-requirements.html[Submit Requirements documentation].
 
-A Prolog submit rule has access to link:prolog-change-facts.html[
-information] about the change for which it is testing the
-submittability. Among others the list of the modified files can be
-accessed, which allows special logic if certain files are touched. For
-example, a common practice is to require a vote on an additional label,
-like `Library-Compliance`, if the dependencies of the project are
-changed.
+== Prolog Submit Rules (DEPRECATED)
 
-[[prolog-submit-type]]
-It is also possible to control the link:prolog-cookbook.html#SubmitType[
-submit type] from Prolog. For example this can be used to define a more
-restrictive submit type such as `Fast Forward Only` for stable branches
-while using a more liberal submit type, e.g. `Merge If Necessary` with
-content merge, for development branches. How this can be done can be
-seen from an link:prolog-cookbook.html#SubmitTypePerBranch[example] in
-the Prolog cookbook.
+The ability to configure submit rules with prolog is deprecated and replaced
+by link:#submit-requirements[Submit Requirements].
 
-Submit rules are maintained in the link:prolog-cookbook.html#RulesFile[
-rules.pl] file in the `refs/meta/config` branch of the project. How to
-write submit rules is explained in the
-link:prolog-cookbook.html#HowToWriteSubmitRules[Prolog cookbook]. There
-is also good support for link:prolog-cookbook.html#TestingSubmitRules[
-testing submit rules] while developing them.
+If you wish to read more about the deprecated feature, read about it in
+link:prolog-cookbook.html[the Prolog cookbook].
+
 
 [[continuous-integration]]
 == Continuous Integration
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 8b6049e..ab77d1b 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1137,6 +1137,45 @@
 ----
 
 
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+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.
+
+----
+
+
 [[highlightjs-structured-text]]
 highlightjs-structured-text
 
diff --git a/Documentation/json.txt b/Documentation/json.txt
index dc82ad1..94f9de9 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -118,7 +118,11 @@
 
   REWORK;; Nontrivial content changes.
 
-  TRIVIAL_REBASE;; Conflict-free merge between the new parent and the prior patch set.
+  TRIVIAL_REBASE;; Conflict-free merge between the new parent and the prior patch set; same commit
+  message.
+
+  TRIVIAL_REBASE_WITH_MESSAGE_UPDATE;; Conflict-free merge between the new parent and the prior
+  patch set.
 
   MERGE_FIRST_PARENT_UPDATE;; Conflict-free change of first (left) parent of a merge commit.
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 7e3fa9a..91b6227 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -4021,6 +4021,45 @@
 ----
 
 
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+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.
+
+----
+
+
 [[highlightjs-structured-text]]
 highlightjs-structured-text
 
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 13873ed..67f0565 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.9.4:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.9.4.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 4302a35..a510e25 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -73,6 +73,26 @@
 ** `cancellation_type`:
    The cancellation type (graceful or forceful).
 
+[[performance]]
+=== Performance
+
+* `performance/operations`: Latency of performing operations
+** `operation_name`:
+   The operation that was performed.
+** `request`:
+   The request for which the operation was performed (format = '<request-type>
+   <redacted-request-uri>').
+** `plugin`:
+   The name of the plugin that performed the operation.
+* `performance/operations_count`: Number of performed operations
+** `operation_name`:
+   The operation that was performed.
+** `request`:
+   The request for which the operation was performed (format = '<request-type>
+   <redacted-request-uri>').
+** `plugin`:
+   The name of the plugin that performed the operation.
+
 === Pushes
 
 * `receivecommits/changes`: histogram of number of changes processed in a single
@@ -98,6 +118,16 @@
 ** `type`:
    The type of the update (CREATE, UPDATE, CREATE/UPDATE, UPDATE_NONFASTFORWARD,
    DELETE).
+* `receivecommits/reject_count`: number of rejected pushes
+** `kind`:
+   The push kind ('magic push'/'magic push by service user' if it was a push for
+   code review, 'direct push'/'direct push by service user' if it was a direct
+   push, 'magic push by service, 'magic or direct push'/'magic or direct push by
+   service user' if the push kind couldn't be detected).
+** `reason`:
+   The rejection reason.
+** `status`:
+   The HTTP status code.
 
 === Process
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 0e1dfd0..29c794c 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -54,7 +54,7 @@
 The metadata is a notes branch. The commit messages on the branch hold
 modifications to global data of the change (votes, global comments). The inline
 comments are in a
-link:https://git.eclipse.org/r/plugins/gitiles/jgit/jgit/\+/master/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java[NoteMap],
+link:https://eclipse.gerrithub.io/plugins/gitiles/eclipse-jgit/jgit/\+/refs/heads/master/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java[NoteMap],
 where the key is the commit SHA-1 of the patchset
 that the comment refers to, and the value is JSON data. The format of the
 JSON is in the
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 04e1b96..b76f567 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -69,10 +69,24 @@
 link:rest-api-changes.html#revision-info[RevisionInfo]
 
 === change-metadata-item
-The `change-metadata-item` extension point is located on the bottom of the
-change view left panel, under the `Label Status` and `Links` sections. Its width
-is equal to the left panel's, and its primary purpose is to allow plugins to add
-sections of metadata to the left panel.
+The `change-metadata-item` extension point is located on the change view
+left panel, below the `Submit Requirements` and `Links` sections by default.
+Its width is equal to the left panel's, and its primary purpose is to allow
+plugins to add sections of metadata to the left panel.
+
+Plugins can set `slot` to `above-submit-requirements` to place the item above
+the `Submit Requirements` and `Links` sections.
+
+Sample code which sets the `slot`:
+
+``` js
+Gerrit.install(
+  plugin => {
+    plugin.registerCustomComponent(
+      'change-metadata-item', 'module-name',
+        {'slot': "above-submit-requirements"});
+});
+```
 
 In addition to default parameters, the following are available:
 
@@ -166,6 +180,9 @@
 === reply-label-scores
 This endpoint decorator wraps the voting buttons in the reply dialog.
 
+=== formatted-text-endpoint
+This endpoint decorator wraps the formatted text.
+
 === header-title
 This endpoint wraps the title-text in the application header.
 
diff --git a/Documentation/pgm-index.txt b/Documentation/pgm-index.txt
index 8f4cbda..e8f5865 100644
--- a/Documentation/pgm-index.txt
+++ b/Documentation/pgm-index.txt
@@ -15,18 +15,12 @@
 link:pgm-daemon.html[daemon]::
 	Gerrit HTTP, SSH network server.
 
-link:pgm-prolog-shell.html[prolog-shell]::
-	Simple interactive Prolog interpreter.
-
 link:pgm-reindex.html[reindex]::
 	Rebuild the secondary index.
 
 link:pgm-SwitchSecureStore.html[SwitchSecureStore]::
 	Change used SecureStore implementation.
 
-link:pgm-rulec.html[rulec]::
-	Compile project-specific Prolog rules to JARs.
-
 version::
 	Display the release version of Gerrit Code Review.
 
@@ -44,6 +38,14 @@
 link:pgm-MigrateAccountPatchReviewDb.html[MigrateAccountPatchReviewDb]::
 	Migrates AccountPatchReviewDb from one database backend to another.
 
+=== Prolog Utilities (DEPRECATED)
+
+link:pgm-prolog-shell.html[prolog-shell]::
+	Simple interactive Prolog interpreter.
+
+link:pgm-rulec.html[rulec]::
+	Compile project-specific Prolog rules to JARs.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/pgm-passwd.txt b/Documentation/pgm-passwd.txt
index 133fb03..02c87e5 100644
--- a/Documentation/pgm-passwd.txt
+++ b/Documentation/pgm-passwd.txt
@@ -27,9 +27,9 @@
 
 == ARGUMENTS
 
-SECTION.KEY::
-	Section and key in the `secure.config` file for setting or editing the
-	password value.
+SECTION.[SUBSECTION.]KEY::
+	Section, subsection and key separated by a dot of the
+	password to set. Subsection is optional.
 
 PASSWORD::
 	New password to set in `secure.config` associated to the section and key.
diff --git a/Documentation/pgm-reindex.txt b/Documentation/pgm-reindex.txt
index 183c132..2946d44a 100644
--- a/Documentation/pgm-reindex.txt
+++ b/Documentation/pgm-reindex.txt
@@ -44,6 +44,16 @@
 	populated disk caches on large Gerrit sites, it is recommended that
 	bloom filters are disabled to improve performance.
 
+--reuse::
+	Reuse the change documents that already exist instead of
+	recreating the whole index from scratch. Each existing document in
+	the index will be checked and reindexed if found to be stale.
+
+	NOTE: Only supported when reindexing changes.
+
+	Use this option if offline reindexing is restarted or crashed.
+	Without this option a restart recreates the complete index
+	from scratch without reusing existing index documents.
 
 == CONTEXT
 The secondary index must be enabled. See
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 6c77109..af944cf 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -3,7 +3,8 @@
 
 [WARNING]
 Prolog rules are no longer supported in Gerrit. Existing usages of prolog rules
-can be modified or deleted, but uploading new "rules.pl" files are rejected.
+can be modified or deleted. Uploading new "rules.pl" files will result in
+a warning being emitted.
 Please use link:config-submit-requirements.html[submit requirements] instead.
 Note that the link:#SubmitType[Submit Type] being deprecated in this
 documentation page currently has no substitution in submit requirements.
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 991f36c..2e1bc43 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -415,6 +415,9 @@
 Links to the history of the configuration file governing this project's access
 rights as list of link:rest-api-changes.html#web-link-info[WebLinkInfo]
 entities.
+|`require_change_for_config_update`       |not set if `false`|
+Whether the calling user must create a change for updating project config.
+If true, all API requests which directly update project config are rejected.
 |==================================
 
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index d0c4553..58f2a5c 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -233,7 +233,8 @@
     "_account_id": 1000096,
     "name": "John Doe",
     "email": "john.doe@example.com",
-    "username": "john"
+    "secondary_emails": [],
+    "username": "john",
     "display_name": "Super John"
   }
 ----
@@ -611,6 +612,91 @@
 "`404 Not Found`" is returned as response. Requests to obtain an access
 token of another user are rejected with "`403 Forbidden`".
 
+[[get-state]]
+=== Get Account State
+--
+'GET /accounts/link:#account-id[\{account-id\}]/state'
+--
+
+Retrieves the superset of all information related to an account. This
+information is useful to inspect issues with the account and its permissions.
+The account state is returned as an link:#account-state-info[AccountStateInfo]
+entity.
+
+Users can only get the own account state. Getting the account state of other
+users is not allowed. To invoke the REST endpoint users can use 'self' as
+account-id in the URL, which resolves to the calling user.
+
+.Request
+----
+  GET /accounts/self/state HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "account": {
+      "registered_on": "2015-07-23 07:01:09.296000000",
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com",
+      "secondary_emails": [],
+      "username": "john",
+      "display_name": "Super John"
+    },
+    "capabilities": {
+      "queryLimit": {
+        "min": 0,
+        "max": 500
+      },
+      "emailReviewers": true
+    },
+    "groups": [
+      {
+        "options": {},
+        "id": "global%3AAnonymous-Users",
+        "name": "Anonymous Users"
+      },
+      {
+        "options": {},
+        "id": "global%3ARegistered-Users",
+        "name": "Registered Users"
+      },
+      {
+        "url": "#/admin/groups/uuid-834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
+        "options": {
+          "visible_to_all": true,
+        },
+        "description": "Users that maintain the project",
+        "group_id": 6,
+        "owner": "Administrators",
+        "owner_id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7"
+        "created_on": "2023-08-08 15:53:56.000000000",
+        "id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
+        "name": "Maintainers"
+      }
+    ],
+    "external_ids": [
+      {
+        "identity": "username:john",
+        "trusted": true
+      },
+      {
+        "identity": "mailto:john.doe@example.com",
+        "email_address": "john.doe@example.com",
+        "trusted": true,
+        "can_delete": true
+      }
+    ],
+    "metadata": {}
+  }
+----
+
 [[list-account-emails]]
 === List Account Emails
 --
@@ -1194,31 +1280,27 @@
   )]}'
   [
     {
+      "options": {},
       "id": "global%3AAnonymous-Users",
-      "url": "#/admin/groups/uuid-global%3AAnonymous-Users",
-      "options": {
-      },
-      "description": "Any user, signed-in or not",
-      "group_id": 2,
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "name": "Anonymous Users"
     },
     {
-      "id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
+      "options": {},
+      "id": "global%3ARegistered-Users",
+      "name": "Registered Users"
+    },
+    {
       "url": "#/admin/groups/uuid-834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
       "options": {
         "visible_to_all": true,
       },
+      "description": "Gerrit Site Administrators",
       "group_id": 6,
+      "owner": "Administrators",
       "owner_id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7"
-    },
-    {
-      "id": "global%3ARegistered-Users",
-      "url": "#/admin/groups/uuid-global%3ARegistered-Users",
-      "options": {
-      },
-      "description": "Any signed-in user",
-      "group_id": 3,
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "created_on": "2023-08-08 15:53:56.000000000",
+      "id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
+      "name": "Administrators"
     }
   ]
 ----
@@ -1317,6 +1399,7 @@
     "work_in_progress_by_default": true,
     "allow_browser_notifications": true,
     "allow_suggest_code_while_commenting": true,
+    "allow_autocompleting_comments": true,
     "diff_page_sidebar": "plugin-foo",
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
@@ -1372,6 +1455,7 @@
     "disable_token_highlighting": true,
     "allow_browser_notifications": false,
     "allow_suggest_code_while_commenting": false,
+    "allow_autocompleting_comments": false,
     "diff_page_sidebar": "NONE",
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
@@ -2259,6 +2343,32 @@
 If not set or if set to an empty string, the account name is deleted.
 |=============================
 
+[[account-state-info]]
+=== AccountStateInfo
+The `AccountStateInfo` entity contains the superset of all information related
+to an account.
+
+[options="header",cols="1,^2,4"]
+|==========================
+|Field Name    ||Description
+|`account`     ||
+The account details as link:#account-detail-info[AccountDetailInfo] entity.
+|`capabilities`|optional|
+The global capabilities of the account as a
+link:#capability-info[CapabilityInfo] entity. Not set if the permission backend
+doesn't use default capabilities.
+|`groups`      ||
+The groups that contain the account as a member as a list of
+link:rest-api-groups.html#group-info[GroupInfo] entries.
+|`external_ids`||
+The external IDs of the account as a list of
+link:#account-external-id-info[AccountExternalIdInfo] entities.
+|`metadata`    ||
+Optional account metadata as a list of
+link:rest-api-config.html#metadata-info[MetadataInfo] entities. If and which
+metadata is provided depends on the Gerrit setup.
+|==========================
+
 [[account-status-input]]
 === AccountStatusInput
 The `AccountStatusInput` entity contains information for setting a status
@@ -2723,6 +2833,9 @@
 |`allow_suggest_code_while_commenting`  |not set if `false`|
 Whether to receive suggested code while writing comments. This feature needs
 a plugin implementation.
+|`allow_autocompleting_comments`  |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
 |`diff_page_sidebar`            |optional|
 String indicating which sidebar should be open on the diff page. Set to "NONE"
 if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
@@ -2801,6 +2914,9 @@
 |`allow_suggest_code_while_commenting`  |not set if `false`|
 Whether to receive suggested code while writing comments. This feature needs
 a plugin implementation.
+|`allow_autocompleting_comments`  |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
 |`diff_page_sidebar`            |optional|
 String indicating which sidebar should be open on the diff page. Set to "NONE"
 if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index f059bf9..c199d82 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -63,7 +63,8 @@
     "_number": 4711,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -126,6 +127,7 @@
       "owner": {
         "name": "John Doe"
       },
+      "current_revision_number": 2
     },
     {
       "id": "demo~master~I09c8041b5867d5b33170316e2abc34b79bbb8501",
@@ -143,6 +145,7 @@
       "owner": {
         "name": "John Doe"
       },
+      "current_revision_number": 2,
       "_more_changes": true
     }
   ]
@@ -214,7 +217,8 @@
         "labels": {
           "Verified": {},
           "Code-Review": {}
-        }
+        },
+        "current_revision_number": 2
       }
     ],
     [],
@@ -437,6 +441,7 @@
       "owner": {
         "name": "Shawn Pearce"
       },
+      "current_revision_number": 1,
       "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c",
       "revisions": {
         "184ebe53805e102605d11f6b143486d15c23a09c": {
@@ -598,7 +603,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -680,7 +686,8 @@
       "_number": 3965,
       "owner": {
         "name": "John Doe"
-      }
+      },
+      "current_revision_number": 2
     },
     "new_change_info": {
       "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
@@ -709,7 +716,8 @@
       "_number": 3965,
       "owner": {
         "name": "John Doe"
-      }
+      },
+      "current_revision_number": 2
     },
   }
 ----
@@ -978,7 +986,8 @@
         "message": "Patch Set 1:\n\nThis is the second message.\n\nWith a line break.",
         "_revision_number": 1
       }
-    ]
+    ],
+    "current_revision_number": 2
   }
 ----
 
@@ -1040,6 +1049,7 @@
     "owner": {
       "_account_id": 1000000
     },
+    "current_revision_number": 1,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
   }
 ----
@@ -1268,7 +1278,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1339,7 +1350,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1411,6 +1423,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
@@ -1561,6 +1574,7 @@
         "owner": {
           "_account_id": 1000000
         },
+        "current_revision_number": 2,
         "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
         "revisions": {
           "c3b2ba222d42a56e05c90f88d4509a124620517d": {
@@ -1638,6 +1652,7 @@
         "owner": {
           "_account_id": 1000000
         },
+        "current_revision_number": 2,
         "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
         "revisions": {
           "77eb17a9501a5c21963bc6af56085e60f281acbb": {
@@ -1762,7 +1777,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1841,7 +1857,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1935,7 +1952,8 @@
         "_number": 3965,
         "owner": {
           "name": "John Doe"
-        }
+        },
+        "current_revision_number": 2
       },
       {
         "id": "anyProject~master~1eee2c9d8f352483781e772f35dc586a69ff5646",
@@ -1953,7 +1971,8 @@
         "_number": 3966,
         "owner": {
           "name": "Jane Doe"
-        }
+        },
+        "current_revision_number": 2
       }
     ]
 ----
@@ -2038,7 +2057,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -2191,6 +2211,7 @@
           }
         ]
       },
+      "current_revision_number": 1,
       "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
       "revisions": {
         "9adb9f4c7b40eeee0646e235de818d09164d7379": {
@@ -2288,6 +2309,7 @@
           }
         ]
       },
+      "current_revision_number": 1,
       "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
       "revisions": {
         "1bd7c12a38854a2c6de426feec28800623f492c4": {
@@ -2397,6 +2419,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 1,
     "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c"
   }
 ----
@@ -2661,6 +2684,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "problems": [
       {
         "message": "Current patch set 1 not found"
@@ -2713,6 +2737,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "problems": [
       {
         "message": "Current patch set 2 not found"
@@ -2726,7 +2751,7 @@
   }
 ----
 
-[[set-work-in-pogress]]
+[[set-work-in-progress]]
 === Set Work-In-Progress
 --
 'POST /changes/link:#change-id[\{change-id\}]/wip'
@@ -3609,6 +3634,9 @@
 
 Rebases change edit on top of latest patch set.
 
+Optionally, input parameters may be specified in the request body as a
+link:#rebase-change-edit-input[RebaseChangeEditInput] entity.
+
 If one of the secondary emails associated with the user performing the operation was used as the
 committer email in the latest patch set, the same email will be used as the committer email in the
 new change edit commit; otherwise, the user's preferred email will be used.
@@ -3618,16 +3646,48 @@
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:rebase HTTP/1.0
 ----
 
-When change was rebased on top of latest patch set, response
-"`204 No Content`" is returned. When change edit is already
-based on top of the latest patch set, the response
-"`409 Conflict`" is returned.
+When the change was rebased on top of latest patch set, the response code is
+"`200 OK`" and the rebased change edit is returned as an
+link:#edit-info[EditInfo] entity.
 
 .Response
 ----
-  HTTP/1.1 204 No Content
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": {
+      "parents": [
+        {
+          "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646",
+        }
+      ],
+      "author": {
+        "name": "Shawn O. Pearce",
+        "email": "sop@google.com",
+        "date": "2012-04-24 18:08:08.000000000",
+        "tz": -420
+       },
+       "committer": {
+         "name": "Shawn O. Pearce",
+         "email": "sop@google.com",
+         "date": "2012-04-24 18:08:08.000000000",
+         "tz": -420
+       },
+       "subject": "Use an EventBus to manage star icons",
+       "message": "Use an EventBus to manage star icons\n\nImage widgets that need to ..."
+    },
+    "base_patch_set_number": s,
+    "base_revision": "c35558e0925e6985c91f3a16921537d5e572b7a3",
+    "ref": "refs/users/01/1000001/edit-76482/2"
+  }
 ----
 
+When the change edit is already based on top of the latest patch set, the
+response code is "`409 Conflict`".
+
 [[delete-edit]]
 === Delete Change Edit
 --
@@ -3817,6 +3877,9 @@
 
 Adds one user or all members of one group as reviewer to the change.
 
+NOTE: Adding multiple reviewers at once is possible via the
+link:#set-review[Set Review] REST endpoint.
+
 The reviewer to be added to the change must be provided in the request
 body as a link:#reviewer-input[ReviewerInput] entity.
 
@@ -4056,9 +4119,16 @@
 If another user removed a user's vote, the user with the deleted vote will be
 added to the attention set.
 
+Note, removing votes is generally disallowed for merged changes as this could
+remove approvals that were necessary for the submission and it's confusing to
+see a merged change which doesn't have the necessary approvals to fulfill the
+submit requirements.
+
 The request returns:
  * '204 No Content' if the vote is deleted successfully;
  * '404 Not Found' when the vote to be deleted is zero or not present.
+ * '409 Conflict' when the change is merged and hence deleting votes is not
+   allowed
 
 .Request
 ----
@@ -4427,6 +4497,7 @@
         }
       ]
     },
+    "current_revision_number": 2,
     "current_revision": "674ac754f91e64a0efb8087e59a176484bd534d1",
     "revisions": {
       "674ac754f91e64a0efb8087e59a176484bd534d1": {
@@ -4818,6 +4889,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
@@ -4918,7 +4990,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -5767,7 +5840,7 @@
   Content-Disposition: attachment
   Content-Type: text/plain; charset=UTF-8
 
-  The existing change edit could not be merged with another tree.
+  Rebasing change edit onto another patchset results in merge conflicts. Download the edit patchset and rebase manually to preserve changes.
 ----
 
 [[apply-provided-fix]]
@@ -5778,7 +5851,12 @@
 Applies a list of <<fix-replacement-info,FixReplacementInfo>> loaded from the
 <<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the request body. The
 application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
-patchset.
+patchset. To apply a fix that was suggested to a non-current patchset, set `originalPatchsetForFix` to a patchset
+number where the fix was suggested. When `originalPatchsetForFix` is set, gerrit generates a patch (using
+`fix-replacement-info` and `originalPatchsetForFix`) and then tries to apply it to the current patchset.
+If it is not possible to apply a patch, an error is returned (see below for the list of errors).
+If the provided fix modifies the commit message and the message was changed between `originalPatchsetForFix`
+and the current patchset, the request is rejected..
 
 If one of the secondary emails associated with the user performing the operation was used as the
 committer email in the current patch set, the same email will be used as the committer email in the
@@ -6491,7 +6569,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -6949,6 +7028,8 @@
 The patch to be applied. Must be compatible with `git diff` output.
 For example, link:#get-patch[Get Patch] output.
 The patch must be provided as UTF-8 text, either directly or base64-encoded.
+|`allow_conflicts`    |optional|
+If true, tolerate conflicts and add conflict markers where required.
 |=================================
 
 [[applypatchpatchset-input]]
@@ -7185,15 +7266,19 @@
 Number of inserted lines.
 |`deletions`          ||
 Number of deleted lines.
-|`total_comment_count`  |optional|
-Total number of inline comments across all patch sets. Not set if the current
-change index doesn't have the data.
-|`unresolved_comment_count`  |optional|
-Number of unresolved inline comment threads across all patch sets. Not set if
-the current change index doesn't have the data.
+|`total_comment_count`  ||
+Total number of inline comments across all patch sets.
+|`unresolved_comment_count`  ||
+Number of unresolved inline comment threads across all patch sets.
 |`_number`            ||
 The change number. (The underscore is just a relict of a prior
 attempt to deprecate the change number.)
+|`virtual_id_number`  ||
+The virtual id number is globally unique. For local changes, it is equal to the
+`_number` attribute. For imported changes, the original `_number` is processed
+through a function designed to prevent conflicts with local change numbers.
+Note that its usage is intended solely for Gerrit's internals and UI, and
+adoption outside these scenarios is not advised.
 |`owner`              ||
 The owner of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
@@ -7260,6 +7345,8 @@
 Messages associated with the change as a list of
 link:#change-message-info[ChangeMessageInfo] entities. +
 Only set if link:#messages[messages] are requested.
+|`current_revision_number`||The number of the current patch set of this
+change. +
 |`current_revision`   |optional|
 The commit ID of the current patch set of this change. +
 Only set if link:#current-revision[the current revision] is requested
@@ -7311,9 +7398,10 @@
 Only set if this change info is returned in response to a request that
 creates a new change or patch set and conflicts are allowed. In
 particular this field is only populated if the change info is returned
-by one of the following REST endpoints: link:#create-change[Create
-Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
-For Change], link:#cherry-pick[Cherry Pick Revision],
+by one of the following REST endpoints:link:#apply-patch[Apply Patch],
+link:#create-change[Create Change],
+link:#create-merge-patch-set-for-change[Create Merge Patch Set For Change],
+link:#cherry-pick[Cherry Pick Revision],
 link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
 link:#rebase-change[Rebase Change]
 |==================================
@@ -7688,7 +7776,8 @@
 message).
 |`full_message` |Full commit message of the change.
 |`footers`      |The footers from the commit message as a map of
-key-value pairs.
+key-value pairs. If there are multiple footers with the same key, only the last
+footer with that key is returned.
 |============================
 
 [[commit-message-input]]
@@ -7970,6 +8059,12 @@
 |`files`                |optional|
 The files of the change edit as a map that maps the file names to
 link:#file-info[FileInfo] entities.
+|`contains_git_conflicts`  |optional, not set if `false`|
+Whether the change edit contains conflicts. +
+If `true`, some of the file contents of the change edit contain git conflict
+markers to indicate the conflicts. +
+Only set if this edit info is returned in response to a request that
+link:#rebase-edit[rebases the change edit] and conflicts are allowed.
 |===========================
 
 [[fetch-info]]
@@ -8428,6 +8523,21 @@
 |`end`        | Last index.
 |===========================
 
+[[rebase-change-edit-input]]
+=== RebaseChangeEditInput
+The `RebaseChangeEditInput` entity contains information for rebasing a change edit.
+
+[options="header",cols="1,^1,5"]
+|====================================
+|Field Name             ||Description
+|`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:#edit-info[EditInfo].
+|====================================
+
 [[rebase-input]]
 === RebaseInput
 The `RebaseInput` entity contains information for changing parent when rebasing.
@@ -8747,9 +8857,8 @@
 action. Not set if false.
 |`error`                  |optional|
 Error message for non-200 responses.
-|`change_info`            |optional|
-Post-update change information. Only set if `response_format_options` were
-set.
+|`change_info`            ||
+Post-update change information.
 |============================
 
 [[reviewer-info]]
@@ -8853,7 +8962,8 @@
 |===========================
 |Field Name    ||Description
 |`kind`        ||The change kind. Valid values are `REWORK`, `TRIVIAL_REBASE`,
-`MERGE_FIRST_PARENT_UPDATE`, `NO_CODE_CHANGE`, and `NO_CHANGE`.
+`TRIVIAL_REBASE_WITH_MESSAGE_UPDATE`, `MERGE_FIRST_PARENT_UPDATE`,
+`NO_CODE_CHANGE`, and `NO_CHANGE`.
 |`_number`     ||The patch set number, or `edit` if the patch set is an edit.
 |`created`     ||
 The link:rest-api.html#timestamp[timestamp] of when the patch set was
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index b6cbaaa..444cc23 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -152,7 +152,8 @@
     "gerrit": {
       "all_projects": "All-Projects",
       "all_users": "All-Users"
-      "doc_search": true
+      "doc_search": true,
+      "project_state_predicate_enabled": true
     },
     "sshd": {},
     "suggest": {
@@ -164,6 +165,27 @@
   }
 ----
 
+[[account-deactivation]]
+=== AccountDeactivation
+--
+'POST /config/server/deactivate.stale.accounts'
+--
+Queues the link:config-gerrit.html#accountDeactivation[account deactivator] task.
+
+.Request
+----
+  POST /config/server/deactivate.stale.accounts HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Account deactivator task added to work queue."
+----
+
 [[check-consistency]]
 === Check Consistency
 --
@@ -875,6 +897,49 @@
   }
 ----
 
+[[list-experiments]]
+=== List Experiments
+--
+'GET /config/server/experiments'
+--
+
+Lists the experiments that are available in the system.
+
+Requires the caller to have the link:access-control.html#capability_administrateServer[
+Administrate Server] global capability.
+
+As result a map of experiment names to link:#experiment-info[Experiment] entities is returned.
+
+The entries in the map are sorted by experiment name.
+
+.Request
+----
+  GET /config/server/experiments/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "GerritBackendFeature__allow_fix_suggestions_in_comments": {
+      "enabled": false
+    },
+    "GerritBackendFeature__attach_nonce_to_documentation": {
+      "enabled": true
+    }
+  }
+----
+
+It is possible to specify the following options:
+
+[[list-experiments-enabled-only]]
+--
+* `enabled-only`: If specified only enabled experiments are listed.
+--
+
 [[list-tasks]]
 === List Tasks
 --
@@ -931,7 +996,7 @@
       "state": "SLEEPING",
       "start_time": "2014-06-11 12:58:51.508000000",
       "delay": 3287966,
-      "command": "Log File Compressor"
+      "command": "Log File Manager"
     }
   ]
 ----
@@ -1444,74 +1509,176 @@
   Content-Type: application/json; charset=UTF-8
 
   )]}'
-  {
-    "accounts": {
+  [
+    {
       "name": "accounts",
       "versions": {
         "13": {
-          "write": true,
-          "search": true
+          "is_write": true,
+          "is_search": true,
+          "num_docs": 3250
         }
       }
     },
-    "changes": {
+    {
       "name": "changes",
       "versions": {
         "83": {
-          "write": true,
-          "search": true
+          "is_write": true,
+          "is_search": true,
+          "num_docs": 250000
         },
         "84": {
-          "write": true,
-          "search": false
+          "is_write": true,
+          "is_search": false,
+          "num_docs": 150000
         }
       }
     },
-    "groups": {
+    {
       "name": "groups",
       "versions": {
         "10": {
-          "write": true,
-          "search": true
+          "is_write": true,
+          "is_search": true,
+          "num_docs": 500
         }
       }
     },
-    "projects": {
+    {
       "name": "projects",
       "versions": {
         "8": {
-          "write": true,
-          "search": true
+          "is_write": true,
+          "is_search": true,
+          "num_docs": 90
         }
       }
     }
+  [
+----
+
+=== Get Index
+--
+'GET /config/server/indexes/changes'
+--
+
+Get an index used by Gerrit. It provides details about the index versions, which
+index version is used to search and which versions are written to.
+
+.Request
+----
+  'GET /config/server/indexes/changes'
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "changes",
+    "versions": {
+      "83": {
+        "is_write": true,
+        "is_search": true,
+        "num_docs": 250000
+      },
+      "84": {
+        "is_write": true,
+        "is_search": false,
+        "num_docs": 150000
+      }
+    }
+  }
+----
+
+=== List Index Versions
+--
+'GET /config/server/indexes/changes/versions'
+--
+
+Lists versions of an index used by Gerrit.
+
+.Request
+----
+  'GET /config/server/indexes/changes/versions'
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "83": {
+      "is_write": true,
+      "is_search": true,
+      "num_docs": 250000
+    },
+    "84": {
+      "is_write": true,
+      "is_search": false,
+      "num_docs": 150000
+    }
+  }
+----
+
+=== Get Index Version
+--
+'GET /config/server/indexes/changes/versions/85'
+--
+
+Get info about one version of an index used by Gerrit.
+
+.Request
+----
+  'GET /config/server/indexes/changes/versions/84'
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "is_write": true,
+    "is_search": false,
+    "num_docs": 150000
   }
 ----
 
 [[snapshot-index]]
 === Create Index Snapshot
 
-This endpoint allows Gerrit admins to create a snapshot of an index.
-This snapshot can be used as a backup of the index.
+These endpoints allow Gerrit admins to create index snapshots.
+Created snapshots can be used as a backup of the index.
 
-A snapshot of all versions of an index can be created by just using
-the name of the index, e.g. `changes`. Only snapshots of indexes that
-Gerrit currently writes to can be created. An index version can be
-selected by using e.g. `changes~84`. Snapshots of all indexes can be
-created by using `all` instead of an index name.
+It is possible to create a snapshot of all indexes, snapshot of one index or
+snapshot of one index version.
+
+The snapshots will be stored on the server at `$SITE/index/snapshots/$ID`.
+The `$ID` can be optionally provided in link:#snapshot-index-input[SnapshotIndex.Input]
+or will default to the current local time in ISO8601 format.
+
+Only snapshots of indexes that Gerrit currently writes to can be created.
 
 Note, that the creation of multiple snapshots, e.g. of different index
 versions, is not atomic. If a consistent state over multiple indexes is
 required, the server has to be put into read-only mode before creating
 the snapshot.
 
-The snapshots will be stored on the server at `$SITE/index/snapshots/$ID`.
-The `$ID` can be optionally provided in link:#snapshot-index-input[SnapshotIndex.Input]
-or will default to the current local time in ISO8601 format.
+==== Create Snapshot of All Indexes
+--
+'POST /config/server/snapshot.indexes HTTP/1.0'
+--
 
 .Request
 ----
-  PUT /config/server/indexes/all/snapshot HTTP/1.0
+  POST /config/server/snapshot.indexes HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
   {
@@ -1530,9 +1697,16 @@
   }
 ----
 
+==== Create Snapshot of one Index
+--
+'POST /config/server/indexes/link:#index-name[\{index-name\}]/snapshot'
+--
+
+This creates a snapshot of all write index versions of the specified index.
+
 .Request
 ----
-  PUT /config/server/indexes/accounts~13/snapshot HTTP/1.0
+  POST /config/server/indexes/accounts/snapshot HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
   {
@@ -1551,6 +1725,123 @@
   }
 ----
 
+==== Create Snapshot of one Index Version
+--
+'POST /config/server/indexes/link:#index-name[\{index-name\}]/versions/#index-version[\{index-version\}]/snapshot'
+--
+
+This creates a snapshot of one index version of the specified index.
+
+.Request
+----
+  POST /config/server/indexes/changes/versions/84/snapshot HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "id": "snapshot-1"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "snapshot-1"
+  }
+----
+
+=== Reindex an Index Version
+--
+'POST /config/server/indexes/link:#index-name[\{index-name\}]/versions/#index-version[\{index-version\}]/reindex'
+--
+
+This endpoint allows to trigger background reindexing of an index version.  It is
+also supported to specify whether to reuse existing up-to-date (non-stale) index
+documents and whether to notifyListeners or not.
+
+.Request
+----
+  POST /config/server/indexes/changes/versions/84/reindex HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reuse": "true",
+    "notifyListeners": "false"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+----
+
+[[cleanup.changes]]
+=== Cleanup of stale changes
+
+This endpoint allows Gerrit administrators to abandon changes older than some given
+time. This allows to run change cleanup manually outside of the configured schedule or
+if change cleanup has been deactivated.
+
+The change cleanup will run asynchronously.
+
+.Request
+----
+  POST /config/server/cleanup.changes HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "after": "3 months",
+    "if_mergeable": true,
+    "message": "Abandoning stale changes."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Disposition: attachment
+----
+
+[[experiment-endpoints]]
+== Experiment Endpoints
+
+[[get-experiment]]
+=== Get Experiment
+--
+'GET /config/server/experiments/link:#experiment-name[\{experiment-name\}]
+--
+
+Retrieves the details of the experiment with the given name.
+
+Requires the caller to have the link:access-control.html#capability_administrateServer[
+Administrate Server] global capability.
+
+.Request
+----
+  GET /config/server/experiments/mGerritBackendFeature__attach_nonce_to_documentation HTTP/1.0
+----
+
+As response an link:#experiment-info[Experiment] entity is returned that
+describes the experiment.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "enabled": true
+  }
+----
+
 [[ids]]
 == IDs
 
@@ -1564,10 +1855,21 @@
 Gerrit core caches can optionally be prefixed with "gerrit":
 "gerrit-<cache-name>".
 
+[[experiment-name]]
+=== \{experiment-name\}
+The name of the experiment.
+
 [[task-id]]
 === \{task-id\}
 The ID of the task (hex string).
 
+[[index-name]]
+=== \{index-name\}
+The name of the index. Can be any of: "accounts", "changes", "groups", "projects".
+
+[[index-version]]
+=== \{index-version\}
+The version of the index. This is an integer.
 
 [[json-entities]]
 == JSON Entities
@@ -1890,6 +2192,16 @@
 |`new_value`  |The new config value, picked up after reload.
 |======================
 
+[[experiment-info]]
+=== ExperimentInfo
+The `ExperimentInfo` entity contains information about an experiment.
+
+[options="header",cols="1,6"]
+|============================
+|Field Name |Description
+|`enabled`  |Whether the experiment is enabled.
+|============================
+
 [[download-info]]
 === DownloadInfo
 The `DownloadInfo` entity contains information about supported download
@@ -1995,6 +2307,8 @@
 Gerrit base path even if this value is unset.)
 |`edit_gpg_keys`     |not set if `false`|
 Whether to enable the web UI for editing GPG keys.
+|`project_state_predicate_enabled` ||
+link:config-gerrit.html#gerrit.projectStatePredicateEnabled[Whether the instance supports filtering projects by state].
 |`report_bug_url`    |optional|
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
 |`instance_id`       |optional|
@@ -2095,6 +2409,21 @@
 The number of open files.
 |============================
 
+[[metadata-info]]
+=== MetadataInfo
+The `MetadataInfo` entity contains metadata provided by plugins.
+
+[options="header",cols="1,^2,4"]
+|==========================
+|Field Name   ||Description
+|`name`       ||The metadata name. Not guaranteed to be unique, e.g. multiple
+metadata entries with the same name may be returned.
+|`value`      |optional|The metadata value.
+|`description`|optional|A description of the metadata.
+|`web_links`  |optional|A list of web links as
+link:rest-api-changes.html#web-link-info[WebLinkInfo] entities.
+|==========================
+
 [[plugin-config-info]]
 === PluginConfigInfo
 The `PluginConfigInfo` entity contains information about Gerrit
@@ -2198,6 +2527,9 @@
 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.
+|`metadata`                ||
+Optional server metadata as a list of link:#metadata-info[MetadataInfo]
+entities. If and which metadata is provided depends on the Gerrit setup.
 |=======================================
 
 [[snapshot-index-input]]
@@ -2363,6 +2695,22 @@
 name of the user is not set.
 |====================================
 
+[[clean-changes-input]]
+=== CleanChanges.Input
+The `CleanChanges.Input` entity is being used to configure a run
+of change cleanup.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name||Description
+|`after`||Abandon all changes that weren't updated in the
+timespan given here
+|`if_mergeable`|default: `false`|Whether to also abandon changes
+that are mergeable
+|`message`|optional|Message to post to changes abandoned by the
+cleanup
+|=============================
+
 
 GERRIT
 ------
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index fff9d0b..b9fa35a 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -984,6 +984,68 @@
   }
 ----
 
+[[create-config-change]]
+=== Create Config Change for review.
+--
+'PUT /projects/link:#project-name[\{project-name\}]/config:review'
+--
+
+Sets the configuration of a project.
+
+This takes the same input as link:#set-config[Set Config], but creates a pending
+change for review. Like link:#create-change[Create Change], it returns
+a link:#change-info[ChangeInfo] entity describing the resulting change.
+
+.Request
+----
+  PUT /projects/myproject/config:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "description": "demo project",
+    "use_contributor_agreements": "FALSE",
+    "use_content_merge": "INHERIT",
+    "use_signed_off_by": "INHERIT",
+    "create_new_change_for_all_not_in_target": "INHERIT",
+    "enable_signed_push": "INHERIT",
+    "require_signed_push": "INHERIT",
+    "reject_implicit_merges": "INHERIT",
+    "require_change_id": "TRUE",
+    "max_object_size_limit": "10m",
+    "submit_type": "REBASE_IF_NECESSARY",
+    "state": "ACTIVE"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
 [[run-gc]]
 === Run GC
 --
@@ -2467,6 +2529,100 @@
   ]
 ----
 
+DescendingOrder(d)::
+Sort the returned tags in descending order.
++
+.Request
+----
+  GET /projects/work%2Fmy-project/tags?d HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v3.0",
+      "revision": "c628685b3c5a3614571ecb5c8fceb85db9112313",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Signed tag\n-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v1.4.11 (GNU/Linux)\n\niQEcBAABAgAGBQJUMlqYAAoJEPI2qVPgglptp7MH/j+KFcittFbxfSnZjUl8n5IZ\nveZo7wE+syjD9sUbMH4EGv0WYeVjphNTyViBof+stGTNkB0VQzLWI8+uWmOeiJ4a\nzj0LsbDOxodOEMI5tifo02L7r4Lzj++EbqtKv8IUq2kzYoQ2xjhKfFiGjeYOn008\n9PGnhNblEHZgCHguGR6GsfN8bfA2XNl9B5Ysl5ybX1kAVs/TuLZR4oDMZ/pW2S75\nhuyNnSgcgq7vl2gLGefuPs9lxkg5Fj3GZr7XPZk4pt/x1oiH7yXxV4UzrUwg2CC1\nfHrBlNbQ4uJNY8TFcwof52Z0cagM5Qb/ZSLglHbqEDGA68HPqbwf5z2hQyU2/U4\u003d\n\u003dZtUX\n-----END PGP SIGNATURE-----",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 09:02:16.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    },
+    {
+      "ref": "refs/tags/v1.0",
+      "revision": "49ce77fdcfd3398dc0dedbe016d1a425fd52d666",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Annotated tag",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 07:35:03.000000000",
+        "tz": 540
+      }
+    }
+  ]
+----
+
+SortBy(sort-by)::
+Sort the returned tags by one of the supported sort options: ref (default), creation_time.
++
+.Request
+----
+  GET /projects/work%2Fmy-project/tags?sort-by=creation_time HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v1.0",
+      "revision": "49ce77fdcfd3398dc0dedbe016d1a425fd52d666",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Annotated tag",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 07:35:03.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v3.0",
+      "revision": "c628685b3c5a3614571ecb5c8fceb85db9112313",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Signed tag\n-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v1.4.11 (GNU/Linux)\n\niQEcBAABAgAGBQJUMlqYAAoJEPI2qVPgglptp7MH/j+KFcittFbxfSnZjUl8n5IZ\nveZo7wE+syjD9sUbMH4EGv0WYeVjphNTyViBof+stGTNkB0VQzLWI8+uWmOeiJ4a\nzj0LsbDOxodOEMI5tifo02L7r4Lzj++EbqtKv8IUq2kzYoQ2xjhKfFiGjeYOn008\n9PGnhNblEHZgCHguGR6GsfN8bfA2XNl9B5Ysl5ybX1kAVs/TuLZR4oDMZ/pW2S75\nhuyNnSgcgq7vl2gLGefuPs9lxkg5Fj3GZr7XPZk4pt/x1oiH7yXxV4UzrUwg2CC1\nfHrBlNbQ4uJNY8TFcwof52Z0cagM5Qb/ZSLglHbqEDGA68HPqbwf5z2hQyU2/U4\u003d\n\u003dZtUX\n-----END PGP SIGNATURE-----",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 09:02:16.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    }
+  ]
+----
+
 Substring(m)::
 Limit the results to those tags that match the specified substring.
 +
@@ -3492,6 +3648,82 @@
   HTTP/1.1 200 OK
 ----
 
+[[create-labels-change]]
+=== Create Labels Change for review.
+--
+'POST /projects/link:#project-name[\{project-name\}]/labels:review'
+--
+
+Creates/updates/deletes multiple label definitions in this project at once.
+
+This takes the same input as link:#batch-update-labels[Batch Updates Labels],
+but creates a pending change for review. Like
+link:#create-change[Create Change], it returns a link:#change-info[ChangeInfo]
+entity describing the resulting change.
+
+.Request
+----
+  POST /projects/testproj/config:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Labels",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ],
+    "create": [
+      {
+        "name": "Foo-Review",
+        "values": {
+          " 0": "No score",
+          "-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"
+      }
+    ],
+    "update:" {
+      "Bar-Review": {
+        "function": "MaxWithBlock"
+      },
+      "Baz-Review": {
+        "copy_condition": "is:MIN"
+      }
+    }
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
+
 [[submit-requirement-endpoints]]
 == Submit Requirement Endpoints
 
@@ -3685,6 +3917,103 @@
   HTTP/1.1 204 No Content
 ----
 
+[[batch-update-submit-requirements]]
+=== Batch Update Submit Requirements
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements/'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The updates must be specified in the request body as
+link:#batch-submit-requirement-input[BatchSubmitRequirementInput] entity.
+
+The updates are processed in the following order:
+
+1. submit requirements deletions
+2. submit requirements creations
+3. submit requirements updates
+
+.Request
+----
+  POST /projects/My-Project/submit_requirements/ HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Submit Requirements",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ]
+  }
+----
+
+If the submit requirements updates were done successfully the response is "`200 OK`".
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[create-submit-requirements-change]]
+=== Create Submit Requirements Change for review.
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements:review'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+This takes the same input as link:#batch-update-submit-requirements[Batch Update Submit Requirements],
+but creates a pending change for review. Like
+link:#create-change[Create Change], it returns a link:#change-info[ChangeInfo]
+entity describing the resulting change.
+
+.Request
+----
+  POST /projects/testproj/submit_requirements:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Submit Requirements",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ]
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
 [[ids]]
 == IDs
 
@@ -4077,11 +4406,13 @@
 Whether empty commits should be rejected when a change is merged.
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
-|commentlinks                              |optional|
+|`commentlinks`                              |optional|
 Map of commentlink names to link:#commentlink-input[CommentLinkInput]
 entities to add or update on the project. If the given commentlink
 already exists, it will be updated with the given values, otherwise
 it will be created. If the value is null, that entry is deleted.
+|`message`           |optional|
+A commit message for this change.
 |======================================================
 
 [[config-parameter-info]]
@@ -4427,6 +4758,27 @@
 entities that describe the updates that should be done for the labels.
 |=============================
 
+[[batch-submit-requirement-input]]
+=== BatchSubmitRequirementInput
+The `BatchSubmitRequirementInput` entity contains information for batch updating submit requirements
+definitions in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the submit requirements updates in the
+`project.config` file to the `refs/meta/config` branch.
+|`delete`        |optional|
+List of submit requirements that should be deleted.
+|`create`        |optional|
+List of link:#submit-requirement-input[SubmitRequirementInput] entities that
+describe submit requirements that should be created.
+|`update`        |optional|
+Map of label names to link:#submit-requirement-input[SubmitRequirementInput]
+entities that describe the updates that should be done for the submit requirements.
+|=============================
+
 [[project-access-input]]
 === ProjectAccessInput
 The `ProjectAccessInput` describes changes that should be applied to a project
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 9825478..469fcdd 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -32,12 +32,20 @@
 changing the attention set:
 
 * If reviewers are added to a change, then they are added to the attention set.
-  * Exception: A reviewer adding themselves along with a comment or vote.
+  ** Exception: A reviewer adding themselves along with a comment or vote.
 * If an active change is submitted, abandoned or reset to "work in progress",
   then all users are removed from the attention set.
 * Replying (commenting, voting or just writing a change message) removes the
   replying user from the attention set. And it adds all participants of comment
-  conversations that the user is replying to.
+  conversations that the user is replying to. Specifically
+  ** If owner is replying and thread is resolved: all participants who have not
+  given Code-Review yet, are added to the attention set.
+  ** If owner is replying and thread is unresolved: all participants are added
+  to the attention set.
+  ** If non-owner is replying and thread is unresolved: only owner is added to
+  the attention set.
+  ** If non-owner is replying and thread is resolved: all participants who have
+  not given Code-Review yet, are added to the attention set.
 * If a *reviewer* replies, then the change owner (and uploader) are added to the
   attention set.
 * For merged and abandoned changes the owner is added only when a human creates
diff --git a/Documentation/user-changeid.txt b/Documentation/user-changeid.txt
index f965db7..2227707 100644
--- a/Documentation/user-changeid.txt
+++ b/Documentation/user-changeid.txt
@@ -39,7 +39,7 @@
 
 Note that a Change-Id is not necessarily unique within a Gerrit instance. It can
 be reused among different repositories or branches (see below,
-link:user-changeid.html[change-upload]).
+link:user-changeid.html#change-upload[change-upload]).
 
 [[creation]]
 == Creation
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index f420fe7..2da3802 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -245,11 +245,11 @@
 comments had been posted in that notification using "Yes" or "No", for
 example `Gerrit-HasComments: Yes`.
 
-[[Gerrit-HasLabels]]Gerrit-HasLabels::
+[[Gerrit-Has-Labels]]Gerrit-Has-Labels::
 
 In comment emails, the has-labels footer states whether label votes had
 been posted in that notification using "Yes" or "No", for
-example `Gerrit-HasLabels: No`.
+example `Gerrit-Has-Labels: No`.
 
 [[Gerrit-Comment-In-Reply-To]]Gerrit-Comment-In-Reply-To::
 
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 625c2e9..7cc9e29 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -205,6 +205,15 @@
 notify them of new changes will be automatically sent an email
 message when the push is completed.
 
+Pushing for review requires that the target branch exists, except for
+the branch to which "HEAD" points, e.g. "master", and the
+"link:config-project-config.html#refs-meta-config[refs/meta/config]"
+branch that contains the link:config-project-config.html[project
+configuration]. For these branches Gerrit allows pushing an initial
+commit for review even if they don't exist yet. The push creates a
+change for the initial commit and when this change gets submitted the
+target branch gets created automatically.
+
 [[push_options]]
 === Push Options
 
diff --git a/SUBMITTING_PATCHES b/SUBMITTING_PATCHES
index 5a82fd9..14437da 100644
--- a/SUBMITTING_PATCHES
+++ b/SUBMITTING_PATCHES
@@ -62,11 +62,11 @@
 agreements, providing documentation to the project maintainers that
 they have right to redistribute your work under the Apache License:
 
-  https://gerrit-review.googlesource.com/#/settings/agreements
+  https://gerrit-review.googlesource.com/settings/#Agreements
 
 Ensure you have obtained a unique HTTP password to identify yourself:
 
-  https://gerrit-review.googlesource.com/#/settings/http-password
+  https://gerrit-review.googlesource.com/settings/#HTTPCredentials
 
 Ensure you have installed the commit-msg hook that automatically
 generates and inserts a Change-Id line during "git commit".  This can
diff --git a/WORKSPACE b/WORKSPACE
index 1c168c6..3d32937 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -31,17 +31,9 @@
 load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
 
 http_archive(
-    name = "rules_nodejs",
-    patch_args = ["-p1"],
-    patches = ["//tools:rules_nodejs-5.8.4-node_versions.bzl.patch"],
-    sha256 = "8fc8e300cb67b89ceebd5b8ba6896ff273c84f6099fc88d23f24e7102319d8fd",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.4/rules_nodejs-core-5.8.4.tar.gz"],
-)
-
-http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "709cc0dcb51cf9028dd57c268066e5bc8f03a119ded410a13b5c3925d6e43c48",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.4/rules_nodejs-5.8.4.tar.gz"],
+    sha256 = "a1295b168f183218bc88117cf00674bcd102498f294086ff58318f830dd9d9d1",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.5/rules_nodejs-5.8.5.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -117,7 +109,7 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
-    node_version = "20.9.0",
+    node_version = "20.14.0",
     yarn_version = "1.22.19",
 )
 
diff --git a/contrib/hooks/ref-updated_repack-geometric.sh b/contrib/hooks/ref-updated_repack-geometric.sh
new file mode 100755
index 0000000..0fe640d
--- /dev/null
+++ b/contrib/hooks/ref-updated_repack-geometric.sh
@@ -0,0 +1,117 @@
+#!/bin/bash -e
+#
+# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#
+# Best run from the Gerrit ref-updated hook
+#
+
+# Make a simple "least effort" attempt to run geometric repacking after every
+# known update which may have written git objects, all while avoiding overloading
+# a server with too much repacking work.
+
+# The least effort avoids running more than one git repack on the same repo at a
+# time, or while a git gc is already running on a repo (by using .git/gc.pid as
+# a lock). To avoid overloading the server, it also avoids running more than 3
+# git repacks total across all repos. If any of these conditions would be violated,
+# this script simply does nothing and exits. The intention is to avoid doing too
+# much work during a burst, assuming that future updates will likely be good enough
+# to service the repos which were missed.
+#
+# Since this is an event based approach to repository maintenance, it is
+# recommended that another time based GC approach, perhaps a more significant and
+# costly one, repacking refs, creating bitmaps... be used in parallel with this
+# script. This simple policy of "least effort" should keep most repos from
+# degrading much even with very infrequent time based GCs.
+#
+# Since this script uses gc.pid to lock the repo against other git gcs, it means
+# that this script could potentially starve any time based gc maintenance from
+# happening on busy repos. It is therefore advisable for any such time based gc
+# jobs to spin for a while attempting to run if the job cannot acquire the gc.pid
+# lock to help ensure that time based gc also gets a chance to run.
+#
+# In order to be able to skip repacking for each update happening during repacking,
+# this script returns immediately after starting repacking in the background. If
+# this script were to instead block during repacking, it would simply delay
+# repacking for those updates instead of having a consolidating effect. That being
+# said, a smarter script might consider tracking that some updates happened after
+# repacking started and ensure that it gets repacked once again (while still
+# consolidating many updates), but that would likely no longer qualify as least
+# effort.
+#
+
+[ -z "$GERRIT_SITE" ] && { echo "ERROR: GERRIT_SITE not set" ; exit 1 ; }
+[ -z "$GIT_DIR" ] && { echo "ERROR: GIT_DIR not set" ; exit 2 ; }
+
+# ---- Generic ----
+
+debug() { true || echo "---- debug: $@" ; }
+
+cleanup() { [ -n "$GC_LOCK" ] && rm -- "$GCLOCK" ; }
+
+exec_locked() { # <lock> <cmd> [<args>...]
+    local lock=$1 rtn=0
+    shift
+    if ( set -o noclobber ; echo $$ > "$lock" ) > /dev/null 2>&1 ; then
+        GC_LOCK=$lock
+        debug "locked $lock"
+        "$@" || rtn=$?
+        rm -- "$lock" && unset GC_LOCK
+        debug "unlocked $lock"
+        return $rtn
+    fi
+    debug "already locked $lock"
+    return 20
+}
+
+exec_acquired() { # <lock> <max> <cmd> [<args>...]
+    local semaphore=$1 max=$2 rtn=0 slot lock
+    shift 2
+    mkdir -p -- "$semaphore"
+    for slot in $(seq "$max") ; do
+        lock="$semaphore/$slot"
+        touch -- "$lock"
+        exec 3<> "$lock"
+        if flock -n 3 ; then
+            debug "acquired semaphore $slot"
+            "$@" || rtn=$?
+            flock -o 3
+            debug "released semaphore $slot"
+            return $rtn
+        fi
+    done
+    debug "semaphore loaded $semaphore"
+    return 30
+}
+
+# ---- Policy ----
+
+gc_lock() { # <cmd> [<args>...]
+    exec_locked "$LOCK" "$@"
+}
+
+gc_runner() { # <cmd> [<args>...]
+    exec_acquired "$SEMAPHORE" "$MAX_RUNNERS" "$@"
+}
+
+trap cleanup EXIT
+
+MAX_RUNNERS=3
+SEMAPHORE=$GERRIT_SITE/logs/git-geometric.semaphore
+LOCK=$GIT_DIR/gc.pid
+
+gc_runner gc_lock git repack -n -d --no-write-bitmap-index --geometric=2 &
+
diff --git a/java/Main.java b/java/Main.java
index c04db2c..e824a95 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -16,7 +16,7 @@
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
   private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
-  private static final Runtime.Version MIN_JAVA_VERSION = Runtime.Version.parse("11.0.10");
+  private static final Runtime.Version MIN_JAVA_VERSION = Runtime.Version.parse("17.0.5");
 
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index aba8261..19b9607d 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -133,6 +134,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotesCommit;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.plugins.PluginContentScanner;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
@@ -1550,6 +1552,17 @@
     assertThat(res).isEqualTo(expectedContent);
   }
 
+  protected void assertLastCommitAuthorAndShortMessage(
+      String refName, String expectedAuthor, String expectedShortMessage) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref exactRef = repo.exactRef(refName);
+      RevCommit revCommit = rw.parseCommit(exactRef.getObjectId());
+      assertThat(revCommit.getAuthorIdent().getName()).isEqualTo(expectedAuthor);
+      assertThat(revCommit.getShortMessage()).isEqualTo(expectedShortMessage);
+    }
+  }
+
   @CanIgnoreReturnValue
   protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
       throws Exception {
@@ -1576,6 +1589,18 @@
         ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
   }
 
+  /** Creates a submit requirement with all required field. */
+  protected void configSubmitRequirement(Project.NameKey project, String name) throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName(name)
+            .setAllowOverrideInChildProjects(true)
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
+            .build());
+  }
+
   protected void configSubmitRequirement(
       Project.NameKey project, SubmitRequirement submitRequirement) throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
@@ -1613,6 +1638,7 @@
     configLabel(project, label, func, ImmutableList.of(), value);
   }
 
+  @SuppressWarnings("deprecation")
   private void configLabel(
       Project.NameKey project,
       String label,
@@ -1728,6 +1754,16 @@
       @Nullable Class<? extends Module> httpModuleClass,
       @Nullable Class<? extends Module> sshModuleClass)
       throws Exception {
+    return installPlugin(pluginName, sysModuleClass, httpModuleClass, sshModuleClass, null);
+  }
+
+  protected AutoCloseable installPlugin(
+      String pluginName,
+      @Nullable Class<? extends Module> sysModuleClass,
+      @Nullable Class<? extends Module> httpModuleClass,
+      @Nullable Class<? extends Module> sshModuleClass,
+      PluginContentScanner scanner)
+      throws Exception {
     checkStatic(sysModuleClass);
     checkStatic(httpModuleClass);
     checkStatic(sshModuleClass);
@@ -1736,6 +1772,7 @@
             pluginName,
             "http://example.com/" + pluginName,
             pluginUserFactory.create(pluginName),
+            scanner,
             getClass().getClassLoader(),
             sysModuleClass != null ? sysModuleClass.getName() : null,
             httpModuleClass != null ? httpModuleClass.getName() : null,
diff --git a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
index 88b97c7..17e0559 100644
--- a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
@@ -52,6 +52,11 @@
     countsByAccount.remove(accountId.get());
   }
 
+  public void assertReindexAtLeastOnceOf(Account.Id accountId) {
+    assertThat(countsByAccount.asMap().getOrDefault(accountId.get(), 0L)).isAtLeast(1);
+    countsByAccount.remove(accountId.get());
+  }
+
   public void assertNoReindex() {
     assertThat(countsByAccount.asMap()).isEmpty();
   }
diff --git a/java/com/google/gerrit/acceptance/AssertUtil.java b/java/com/google/gerrit/acceptance/AssertUtil.java
index a1d3e79..f72c6d3 100644
--- a/java/com/google/gerrit/acceptance/AssertUtil.java
+++ b/java/com/google/gerrit/acceptance/AssertUtil.java
@@ -25,9 +25,9 @@
 public class AssertUtil {
   public static <T> void assertPrefs(T actual, T expected, String... fieldsToExclude)
       throws IllegalArgumentException, IllegalAccessException {
-    Set<String> exludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
+    Set<String> excludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
     for (Field field : actual.getClass().getDeclaredFields()) {
-      if (exludedFields.contains(field.getName()) || skipField(field)) {
+      if (excludedFields.contains(field.getName()) || skipField(field)) {
         continue;
       }
       Object actualVal = field.get(actual);
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 5991646..98287c8 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -41,6 +41,10 @@
     return countsByChange.get(info._number);
   }
 
+  public long getTotalCount() {
+    return countsByChange.asMap().values().stream().reduce(0L, Long::sum);
+  }
+
   public void assertReindexOf(ChangeInfo info) {
     assertReindexOf(info, 1);
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
index 7660948..4af9a31 100644
--- a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -70,6 +70,11 @@
   }
 
   @Override
+  public int numDocs() {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts) {
     throw new UnsupportedOperationException("AccountIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index c028a8e..4748e31 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -77,6 +77,11 @@
   }
 
   @Override
+  public int numDocs() {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
   public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
       throws QueryParseException {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
index f2aad4a..ec1bcd4 100644
--- a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -75,6 +75,11 @@
   }
 
   @Override
+  public int numDocs() {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
   public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts) {
     throw new UnsupportedOperationException("ProjectIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index d2051d5..5a1de63 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -44,8 +44,9 @@
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
+import com.google.gerrit.server.ServerStateProvider;
+import com.google.gerrit.server.account.AccountStateProvider;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.FilterIncludedIn;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -81,7 +82,6 @@
   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;
   private final DynamicMap<DownloadScheme> downloadSchemes;
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
@@ -108,6 +108,8 @@
   private final DynamicSet<OnPostReview> onPostReviews;
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
+  private final DynamicSet<ServerStateProvider> serverStateProviders;
+  private final DynamicSet<AccountStateProvider> accountStateProviders;
   private final DynamicSet<AttentionSetListener> attentionSetListeners;
 
   private final DynamicMap<ChangeHasOperandFactory> hasOperands;
@@ -128,7 +130,6 @@
       DynamicSet<SubmitRule> submitRules,
       DynamicSet<SubmitRequirement> submitRequirements,
       DynamicSet<ChangeMessageModifier> changeMessageModifiers,
-      DynamicSet<ChangeETagComputation> changeETagComputations,
       DynamicSet<ActionVisitor> actionVisitors,
       DynamicMap<DownloadScheme> downloadSchemes,
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
@@ -156,6 +157,8 @@
       DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
       DynamicMap<ChangeHasOperandFactory> hasOperands,
       DynamicMap<ChangeIsOperandFactory> isOperands,
+      DynamicSet<ServerStateProvider> serverStateProviders,
+      DynamicSet<AccountStateProvider> accountStateProviders,
       DynamicSet<AttentionSetListener> attentionSetListeners,
       DynamicMap<ReviewerSuggestion> reviewerSuggestions) {
     this.accountIndexedListeners = accountIndexedListeners;
@@ -170,7 +173,6 @@
     this.submitRules = submitRules;
     this.submitRequirements = submitRequirements;
     this.changeMessageModifiers = changeMessageModifiers;
-    this.changeETagComputations = changeETagComputations;
     this.actionVisitors = actionVisitors;
     this.downloadSchemes = downloadSchemes;
     this.refOperationValidationListeners = refOperationValidationListeners;
@@ -198,6 +200,8 @@
     this.reviewerDeletedListeners = reviewerDeletedListeners;
     this.hasOperands = hasOperands;
     this.isOperands = isOperands;
+    this.serverStateProviders = serverStateProviders;
+    this.accountStateProviders = accountStateProviders;
     this.attentionSetListeners = attentionSetListeners;
     this.reviewerSuggestions = reviewerSuggestions;
   }
@@ -286,11 +290,6 @@
     }
 
     @CanIgnoreReturnValue
-    public Registration add(ChangeETagComputation changeETagComputation) {
-      return add(changeETagComputations, changeETagComputation);
-    }
-
-    @CanIgnoreReturnValue
     public Registration add(ActionVisitor actionVisitor) {
       return add(actionVisitors, actionVisitor);
     }
@@ -382,6 +381,16 @@
     }
 
     @CanIgnoreReturnValue
+    public Registration add(ServerStateProvider serverStateProvider) {
+      return add(serverStateProviders, serverStateProvider);
+    }
+
+    @CanIgnoreReturnValue
+    public Registration add(AccountStateProvider accountStateProvider) {
+      return add(accountStateProviders, accountStateProvider);
+    }
+
+    @CanIgnoreReturnValue
     public Registration add(AttentionSetListener attentionSetListener) {
       return add(attentionSetListeners, attentionSetListener);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 73631e9..cf4cf2c 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -26,6 +26,7 @@
 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.GrantDirectPushPermissionsOnStartup.GrantDirectPushPermissionsOnStartupModule;
 import com.google.gerrit.acceptance.ReindexGroupsAtStartup.ReindexGroupsAtStartupModule;
 import com.google.gerrit.acceptance.ReindexProjectsAtStartup.ReindexProjectsAtStartupModule;
 import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
@@ -57,6 +58,7 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.Daemon;
 import com.google.gerrit.pgm.Init;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
@@ -334,7 +336,7 @@
     String configuredIndexBackend = cfg.getString("index", null, "type");
     if (configuredIndexBackend == null) {
       // Propagate index type to pgms that run off of the gerrit.config file on local disk.
-      IndexType indexType = IndexType.fromEnvironment().orElse(new IndexType("fake"));
+      IndexType indexType = IndexType.fromEnvironment().orElseGet(() -> new IndexType("fake"));
       gerritConfig.setString("index", null, "type", indexType.isLucene() ? "lucene" : "fake");
     }
     gerritConfig.save();
@@ -513,7 +515,7 @@
     IndexType indexType =
         (configuredIndexBackend != null)
             ? new IndexType(configuredIndexBackend)
-            : IndexType.fromEnvironment().orElse(new IndexType("fake"));
+            : IndexType.fromEnvironment().orElseGet(() -> new IndexType("fake"));
     daemon.setIndexModule(createIndexModule(indexType, baseConfig, testIndexModule));
 
     daemon.setEnableHttpd(desc.httpd());
@@ -524,12 +526,15 @@
             new AbstractModule() {
               @Override
               protected void configure() {
+                bind(GerritOptions.class).toInstance(GerritOptions.DEFAULT);
                 bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
               }
             },
             new ConfigExperimentFeaturesModule()));
     daemon.addAdditionalSysModuleForTesting(
-        new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
+        new GrantDirectPushPermissionsOnStartupModule(),
+        new ReindexProjectsAtStartupModule(),
+        new ReindexGroupsAtStartupModule());
     daemon.start();
     return new GerritServer(desc, null, createTestInjector(daemon), Optional.of(daemon), null);
   }
@@ -554,7 +559,9 @@
       throws Exception {
     requireNonNull(site);
     daemon.addAdditionalSysModuleForTesting(
-        new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
+        new GrantDirectPushPermissionsOnStartupModule(),
+        new ReindexProjectsAtStartupModule(),
+        new ReindexGroupsAtStartupModule());
     ExecutorService daemonService = Executors.newSingleThreadExecutor();
     String[] args =
         Stream.concat(
diff --git a/java/com/google/gerrit/acceptance/GerritServerRestSession.java b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
index c2c77fe..9605f50 100644
--- a/java/com/google/gerrit/acceptance/GerritServerRestSession.java
+++ b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
@@ -142,7 +142,8 @@
     return execute(delete);
   }
 
-  private String getUrl(String endPoint) {
+  @Override
+  public String getUrl(String endPoint) {
     return url + (account != null ? "/a" : "") + endPoint;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/GerritServerTestRule.java b/java/com/google/gerrit/acceptance/GerritServerTestRule.java
index 493652d..d3bc008 100644
--- a/java/com/google/gerrit/acceptance/GerritServerTestRule.java
+++ b/java/com/google/gerrit/acceptance/GerritServerTestRule.java
@@ -268,11 +268,6 @@
     return true;
   }
 
-  @Override
-  public boolean isRefSequenceSupported() {
-    return true;
-  }
-
   public static void afterConfigChanged() {
     if (commonServer != null) {
       try {
diff --git a/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java b/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java
new file mode 100644
index 0000000..fcf4635
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class GrantDirectPushPermissionsOnStartup implements LifecycleListener {
+  public static class GrantDirectPushPermissionsOnStartupModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(GrantDirectPushPermissionsOnStartup.class).in(Scopes.SINGLETON);
+    }
+  }
+
+  private final AllProjectsName allProjects;
+  private final MetaDataUpdate.Server metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final Groups groups;
+
+  @Inject
+  GrantDirectPushPermissionsOnStartup(
+      AllProjectsName allProjects,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      Groups groups) {
+    this.allProjects = allProjects;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.groups = groups;
+  }
+
+  @Override
+  public void start() {
+    try (RefUpdateContext ctx = openTestRefUpdateContext();
+        MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(allProjects)) {
+      ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+      GroupReference adminGroupRef = findAdminGroup().orElseThrow();
+      adminGroupRef = projectConfig.resolve(adminGroupRef);
+      PermissionRule.Builder rule = PermissionRule.builder(adminGroupRef).setAction(Action.ALLOW);
+      projectConfig.upsertAccessSection(
+          RefNames.REFS_HEADS + "*", as -> as.upsertPermission(Permission.PUSH).add(rule));
+      projectConfig.upsertAccessSection(
+          RefNames.REFS_CONFIG, as -> as.upsertPermission(Permission.PUSH).add(rule));
+      projectConfig.commit(metaDataUpdate);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new IllegalStateException(
+          "Unable to assign direct push permissions, tests may fail", e);
+    }
+  }
+
+  @Override
+  public void stop() {}
+
+  private Optional<GroupReference> findAdminGroup() throws IOException, ConfigInvalidException {
+    for (GroupReference groupRef : groups.getAllGroupReferences().collect(toImmutableList())) {
+      InternalGroup group = groups.getGroup(groupRef.getUUID()).orElseThrow();
+      if (group.getName().equals("Administrators")) {
+        return Optional.of(groupRef);
+      }
+    }
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index 9a652e3..c313a06 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
+import com.google.gerrit.server.config.GerritIsReplica;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.MetricsReservoirConfigImpl;
@@ -83,6 +84,8 @@
     install(new SchemaModule());
 
     install(new SshdModule());
+
+    bind(Boolean.class).annotatedWith(GerritIsReplica.class).toInstance(false);
   }
 
   static class CreateSchema implements LifecycleListener {
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index ba1dbbc..2d53533 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -176,7 +176,7 @@
   private final TestRepository<?>.CommitBuilder commitBuilder;
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo)
@@ -185,7 +185,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -202,7 +202,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -212,7 +212,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -224,7 +224,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -236,7 +236,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -256,7 +256,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -275,7 +275,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -303,6 +303,11 @@
     commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
 
+  @UsedAt(Project.GOOGLE)
+  protected TestRepository<?> testRepository() {
+    return testRepo;
+  }
+
   @CanIgnoreReturnValue
   public PushOneCommit setParents(List<RevCommit> parents) throws Exception {
     commitBuilder.noParents();
diff --git a/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
index a9c14aa..a53c015 100644
--- a/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/java/com/google/gerrit/acceptance/RestResponse.java
@@ -103,4 +103,9 @@
     assertStatus(SC_MOVED_TEMPORARILY);
     assertThat(URI.create(getHeader("Location")).getPath()).isEqualTo(path);
   }
+
+  public void assertTemporaryRedirectUri(String uri) throws Exception {
+    assertStatus(SC_MOVED_TEMPORARILY);
+    assertThat(getHeader("Location")).isEqualTo(uri);
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 0865e31..3fefd5b 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -52,4 +52,6 @@
   RestResponse delete(String endPoint) throws Exception;
 
   RestResponse deleteWithHeaders(String endPoint, Header... headers) throws Exception;
+
+  String getUrl(String endPoint);
 }
diff --git a/java/com/google/gerrit/acceptance/ServerTestRule.java b/java/com/google/gerrit/acceptance/ServerTestRule.java
index 5687f91..a057a6e 100644
--- a/java/com/google/gerrit/acceptance/ServerTestRule.java
+++ b/java/com/google/gerrit/acceptance/ServerTestRule.java
@@ -101,7 +101,4 @@
    * (e.g. email)
    */
   boolean isUsernameSupported();
-
-  /** Returns true if ref sequences are stored in NoteDb. */
-  boolean isRefSequenceSupported();
 }
diff --git a/java/com/google/gerrit/acceptance/TestConfigRule.java b/java/com/google/gerrit/acceptance/TestConfigRule.java
index a7f051a..e2ae416 100644
--- a/java/com/google/gerrit/acceptance/TestConfigRule.java
+++ b/java/com/google/gerrit/acceptance/TestConfigRule.java
@@ -47,8 +47,11 @@
       @Override
       public void evaluate() throws Throwable {
         setTestConfigFromDescription(description);
-        statement.evaluate();
-        clear();
+        try {
+          statement.evaluate();
+        } finally {
+          clear();
+        }
       }
     };
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 577cda8..bdda27a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -28,12 +28,12 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureStatelessDelta;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
-import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
@@ -71,9 +71,9 @@
         this::createAccount, externalIdFactory.arePasswordsAllowed());
   }
 
-  private Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
+  protected Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
-    Consumer<AccountDelta.Builder> accountCreation =
+    ConfigureStatelessDelta accountCreation =
         deltaBuilder -> initAccountDelta(deltaBuilder, testAccountCreation, accountId);
     AccountState createdAccount =
         accountsUpdate.insert("Create Test Account", accountId, accountCreation);
@@ -234,7 +234,7 @@
 
       if (testAccountInvalidation.preferredEmailWithoutExternalId().isPresent()) {
         updateAccount(
-            (account, deltaBuilder) ->
+            (unusedState, deltaBuilder) ->
                 deltaBuilder.setPreferredEmail(
                     testAccountInvalidation.preferredEmailWithoutExternalId().get()));
       }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index dbcfceb..971347c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -20,7 +20,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -396,7 +395,7 @@
   }
 
   private static <T> ImmutableList<T> asImmutableList(Optional<T> value) {
-    return Streams.stream(value).collect(toImmutableList());
+    return value.stream().collect(toImmutableList());
   }
 
   private static TreeCreator getTreeCreator(
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index 0a22688..5f8f3e7 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -78,7 +78,7 @@
 
   private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation) {
     AccountGroup.Id groupId = AccountGroup.id(seq.nextGroupId());
-    String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
+    String groupName = groupCreation.name().orElseGet(() -> "group-with-id-" + groupId.get());
     AccountGroup.UUID groupUuid = GroupUuid.make(groupName, serverIdent);
     AccountGroup.NameKey nameKey = AccountGroup.nameKey(groupName);
     return InternalGroupCreation.builder()
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index bd3d656..a3112f8 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -88,7 +88,7 @@
   }
 
   private Project.NameKey createNewProject(TestProjectCreation projectCreation) throws Exception {
-    String name = projectCreation.name().orElse(RandomStringUtils.randomAlphabetic(8));
+    String name = projectCreation.name().orElseGet(() -> RandomStringUtils.randomAlphabetic(8));
 
     CreateProjectArgs args = new CreateProjectArgs();
     args.setProjectName(name);
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
index 8bad32c..a869752 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
@@ -35,7 +35,7 @@
    * the test and the account must have a username set.
    *
    * <p>The session associated with the returned context can be obtained by calling {@link
-   * com.google.gerrit.acceptance.AbstractDaemonTest#getOrCreateSshSessionForContext}.
+   * com.google.gerrit.acceptance.ServerTestRule#getOrCreateSshSessionForContext}.
    *
    * @param accountId account ID. Must exist; throws an unchecked exception otherwise.
    */
@@ -53,7 +53,7 @@
    * <p>In order to create and use the SSH session for the new context, SSH must be enabled in the
    * test and the account must have a username set. To get the session associated with the newly set
    * context use the {@link
-   * com.google.gerrit.acceptance.AbstractDaemonTest#getOrCreateSshSessionForContext} method.
+   * com.google.gerrit.acceptance.ServerTestRule#getOrCreateSshSessionForContext} method.
    *
    * @param accountId account ID. Must exist; throws an unchecked exception otherwise.
    */
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 8f85311..ff43bbd 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -3,6 +3,7 @@
 ANNOTATIONS = [
     "Nullable.java",
     "UsedAt.java",
+    "ConvertibleToProto.java",
 ]
 
 java_library(
diff --git a/java/com/google/gerrit/common/ConvertibleToProto.java b/java/com/google/gerrit/common/ConvertibleToProto.java
new file mode 100644
index 0000000..65074d4
--- /dev/null
+++ b/java/com/google/gerrit/common/ConvertibleToProto.java
@@ -0,0 +1,12 @@
+package com.google.gerrit.common;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/** An annotation used to mark Java entities that have equivalent proto representations. */
+@Retention(RUNTIME)
+@Target({TYPE})
+public @interface ConvertibleToProto {}
diff --git a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
index 95df5be..c057df8 100644
--- a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
+++ b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common;
 
-import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.common.FileUtil.lastModified;
 import static java.util.stream.Collectors.joining;
 
@@ -36,7 +35,7 @@
     try {
       List<Path> jars = listJars(libdir);
       JarUtil.loadJars(jars);
-      logger.atFine().log("Loaded site libraries: %s", lazy(() -> jarList(jars)));
+      logger.atFine().log("Loaded site libraries: %s", jarList(jars));
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error scanning lib directory %s", libdir);
     }
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 83af551..32264e3 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -45,6 +45,7 @@
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
     PLUGIN_PULL_REPLICATION,
+    PLUGIN_REVIEWERS,
     PLUGIN_SERVICEUSER,
     PLUGIN_WEBSESSION_FLATFILE,
     MODULE_GIT_REFS_FILTER,
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 767e68e..4b7694a 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -22,6 +22,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.primitives.Ints;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import java.time.Instant;
@@ -58,6 +59,7 @@
 
   /** Key local to Gerrit to identify a user. */
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Id implements Comparable<Id> {
     /** Parse an Account.Id out of a string representation. */
     public static Optional<Id> tryParse(String str) {
@@ -162,6 +164,17 @@
   public abstract String metaId();
 
   /**
+   * A unique tag which identifies the current version of the account.
+   *
+   * <p>It can be any non-empty string. For open-source gerrit it is the same as metaId; internally
+   * in google a different value is assigned.
+   *
+   * <p>The value can be null only during account updating/creation.
+   */
+  @Nullable
+  public abstract String uniqueTag();
+
+  /**
    * Create a new account.
    *
    * @param newId unique id, see Sequences#nextAccountId().
@@ -278,6 +291,11 @@
 
     public abstract Builder setMetaId(@Nullable String metaId);
 
+    @Nullable
+    public abstract String uniqueTag();
+
+    public abstract Builder setUniqueTag(@Nullable String uniqueTag);
+
     public abstract Account build();
   }
 
@@ -296,6 +314,7 @@
         .add("inactive", inactive())
         .add("status", status())
         .add("metaId", metaId())
+        .add("uniqueTag", uniqueTag())
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 56fb748..51585f3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gson.Gson;
@@ -104,6 +105,7 @@
 
   /** The numeric change ID */
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Id {
     /**
      * Parse a Change.Id out of a string representation.
@@ -271,6 +273,7 @@
    * "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
    */
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Key {
     // TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key?
     // Ideally the standard key() factory method would enforce the format and throw IAE.
@@ -433,6 +436,9 @@
   /** Locally assigned unique identifier of the change */
   private Id changeId;
 
+  /** ServerId of the Gerrit instance that has created the change */
+  @Nullable private String serverId;
+
   /** Globally assigned unique identifier of the change */
   private Key changeKey;
 
@@ -530,6 +536,23 @@
     return changeId;
   }
 
+  /**
+   * Set the serverId of the Gerrit instance that created the change. It can be set to null for
+   * testing purposes in the protobuf converter tests.
+   */
+  public void setServerId(@Nullable String serverId) {
+    this.serverId = serverId;
+  }
+
+  /**
+   * ServerId of the Gerrit instance that created the change. It could be null when the change is
+   * not fetched from NoteDb but obtained through protobuf deserialisation.
+   */
+  @Nullable
+  public String getServerId() {
+    return serverId;
+  }
+
   /** 32 bit integer identity for a change. */
   public int getChangeId() {
     return changeId.get();
@@ -588,6 +611,7 @@
     return originalSubject != null ? originalSubject : subject;
   }
 
+  @Nullable
   public String getOriginalSubjectOrNull() {
     return originalSubject;
   }
@@ -633,6 +657,7 @@
     originalSubject = null;
   }
 
+  @Nullable
   public String getSubmissionId() {
     return submissionId;
   }
@@ -665,6 +690,7 @@
     return isAbandoned() || isMerged();
   }
 
+  @Nullable
   public String getTopic() {
     return topic;
   }
@@ -701,10 +727,12 @@
     this.revertOf = revertOf;
   }
 
+  @Nullable
   public Id getRevertOf() {
     return this.revertOf;
   }
 
+  @Nullable
   public PatchSet.Id getCherryPickOf() {
     return cherryPickOf;
   }
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index dea070f..c8fc7d2 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import java.time.Instant;
 import java.util.Objects;
@@ -34,6 +35,7 @@
   }
 
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Key {
     public abstract Change.Id changeId();
 
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 1e48f11..325bd6c 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.gerrit.common.ConvertibleToProto;
 import java.time.Instant;
 import java.util.Objects;
 
@@ -26,6 +27,7 @@
  *
  * <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
  */
+@ConvertibleToProto
 public class HumanComment extends Comment {
 
   public boolean unresolved;
diff --git a/java/com/google/gerrit/entities/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
index 2426818..e3b3024 100644
--- a/java/com/google/gerrit/entities/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
 
 @AutoValue
+@ConvertibleToProto
 public abstract class LabelId {
   public static final String LEGACY_SUBMIT_NAME = "SUBM";
   public static final String CODE_REVIEW = "Code-Review";
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index ff4b8f9..c491620 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -207,12 +207,52 @@
     public abstract Builder setDescription(Optional<String> description);
 
     /**
-     * @deprecated in favour of using submit requirements, except if it’s needed to set the value to
-     *     PatchSetLock
+     * @deprecated All label functions except {@code PATCH_SET_LOCK} are deprecated in favour of
+     *     using submit requirements. When submit requirements are used the label function needs to
+     *     be set to {@code NO_BLOCK} (or {@code NO_OP} which is semantically the same). This is to
+     *     override the default label function which is {@code MAX_WITH_BLOCK} and which should not
+     *     be used in combination with a submit requirement.
      */
     @Deprecated
     public abstract Builder setFunction(LabelFunction function);
 
+    /**
+     * Sets the label function to {@code NO_BLOCK}, e.g. to override the default label function
+     * which is {@code MAX_WITH_BLOCK} and which should not be used in combination with a submit
+     * requirement. .
+     *
+     * <p>In contrast to most other label functions {@code NO_BLOCK} is not deprecated.
+     *
+     * <p>Use this method to set the label function to {@code NO_BLOCK}, instead of calling {@code
+     * setFunction(NO_BLOCK)} which is deprecated.
+     *
+     * <p>Note, {@code NO_OP} is semantically the same as {@code NO_BLOCK}, hence this method should
+     * also be used, instead of calling {@code setFunction(NO_OP)}.
+     *
+     * @return the instance of this builder to allow chaining calls.
+     */
+    @CanIgnoreReturnValue
+    @SuppressWarnings("deprecation")
+    public Builder setNoBlockFunction() {
+      return setFunction(LabelFunction.NO_BLOCK);
+    }
+
+    /**
+     * Sets the label function to {@code PATCH_SET_LOCK}.
+     *
+     * <p>In contrast to most other label functions {@code PATCH_SET_LOCK} is not deprecated.
+     *
+     * <p>Use this method to set the label function to {@code PATCH_SET_LOCK}, instead of calling
+     * {@code setFunction(PATCH_SET_LOCK)} which is deprecated.
+     *
+     * @return the instance of this builder to allow chaining calls.
+     */
+    @CanIgnoreReturnValue
+    @SuppressWarnings("deprecation")
+    public Builder setPatchSetLockFunction() {
+      return setFunction(LabelFunction.PATCH_SET_LOCK);
+    }
+
     public abstract Builder setCanOverride(boolean canOverride);
 
     public abstract Builder setAllowPostSubmit(boolean allowPostSubmit);
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
index d3123c4..42baebc 100644
--- a/java/com/google/gerrit/entities/NotifyConfig.java
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import java.util.EnumSet;
 import java.util.Set;
@@ -31,6 +32,7 @@
     BCC
   }
 
+  @ConvertibleToProto
   public enum NotifyType {
     // sort by name, except 'ALL' which should stay last
     ABANDONED_CHANGES,
diff --git a/java/com/google/gerrit/entities/ParentCommitData.java b/java/com/google/gerrit/entities/ParentCommitData.java
index e1fce30..2be6df5 100644
--- a/java/com/google/gerrit/entities/ParentCommitData.java
+++ b/java/com/google/gerrit/entities/ParentCommitData.java
@@ -41,7 +41,7 @@
   public abstract Optional<ObjectId> commitId();
 
   /** Whether the parent commit is merged in the target branch {@link #branchName()}. */
-  public abstract Boolean isMergedInTargetBranch();
+  public abstract boolean isMergedInTargetBranch();
 
   /**
    * Change key of the parent commit. Only set if the parent commit is a patch-set of another gerrit
@@ -79,7 +79,7 @@
 
     public abstract Builder commitId(Optional<ObjectId> commitId);
 
-    public abstract Builder isMergedInTargetBranch(Boolean isMerged);
+    public abstract Builder isMergedInTargetBranch(boolean isMerged);
 
     public abstract Builder changeKey(Optional<Change.Key> changeKey);
 
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index e8759fa..6f71874 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
 import com.google.errorprone.annotations.InlineMe;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import java.time.Instant;
 import java.util.List;
@@ -31,6 +32,7 @@
 
 /** A single revision of a {@link Change}. */
 @AutoValue
+@ConvertibleToProto
 public abstract class PatchSet {
   /** Is the reference name a change reference? */
   public static boolean isChangeRef(String name) {
@@ -67,6 +69,7 @@
   }
 
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Id implements Comparable<Id> {
     /** Parse a PatchSet.Id out of a string representation. */
     public static Id parse(String str) {
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index 608cf0d..f78167b 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.time.Instant;
 import java.util.Optional;
 
@@ -27,6 +28,7 @@
   }
 
   @AutoValue
+  @ConvertibleToProto
   public abstract static class Key {
     public abstract PatchSet.Id patchSetId();
 
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 9c2866c..7b02597 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
@@ -57,6 +58,7 @@
    * <p>This class is immutable and thread safe.
    */
   @Immutable
+  @ConvertibleToProto
   public static class NameKey implements Serializable, Comparable<NameKey> {
     private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
index 1e846fb..6106090 100644
--- a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum AccountIdProtoConverter implements ProtoConverter<Entities.Account_Id, Account.Id> {
+public enum AccountIdProtoConverter implements SafeProtoConverter<Entities.Account_Id, Account.Id> {
   INSTANCE;
 
   @Override
@@ -37,4 +37,14 @@
   public Parser<Entities.Account_Id> getParser() {
     return Entities.Account_Id.parser();
   }
+
+  @Override
+  public Class<Entities.Account_Id> getProtoClass() {
+    return Entities.Account_Id.class;
+  }
+
+  @Override
+  public Class<Account.Id> getEntityClass() {
+    return Account.Id.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java
new file mode 100644
index 0000000..c073a5f
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link AccountInput} and {@link
+ * com.google.gerrit.proto.Entities.AccountInput}.
+ */
+@Immutable
+public enum AccountInputProtoConverter
+    implements ProtoConverter<Entities.AccountInput, AccountInput> {
+  INSTANCE;
+
+  @Override
+  public Entities.AccountInput toProto(AccountInput accountInput) {
+    Entities.AccountInput.Builder builder = Entities.AccountInput.newBuilder();
+    if (accountInput.username != null) {
+      builder.setUsername(accountInput.username);
+    }
+    if (accountInput.name != null) {
+      builder.setName(accountInput.name);
+    }
+    if (accountInput.displayName != null) {
+      builder.setDisplayName(accountInput.displayName);
+    }
+    if (accountInput.email != null) {
+      builder.setEmail(accountInput.email);
+    }
+    if (accountInput.sshKey != null) {
+      builder.setSshKey(accountInput.sshKey);
+    }
+    if (accountInput.httpPassword != null) {
+      builder.setHttpPassword(accountInput.httpPassword);
+    }
+    if (accountInput.groups != null) {
+      builder.addAllGroups(accountInput.groups);
+    }
+
+    return builder.build();
+  }
+
+  @Override
+  public AccountInput fromProto(Entities.AccountInput proto) {
+    AccountInput accountInput = new AccountInput();
+    if (proto.hasUsername()) {
+      accountInput.username = proto.getUsername();
+    }
+    if (proto.hasName()) {
+      accountInput.name = proto.getName();
+    }
+    if (proto.hasDisplayName()) {
+      accountInput.displayName = proto.getDisplayName();
+    }
+    if (proto.hasEmail()) {
+      accountInput.email = proto.getEmail();
+    }
+    if (proto.hasSshKey()) {
+      accountInput.sshKey = proto.getSshKey();
+    }
+    if (proto.hasHttpPassword()) {
+      accountInput.httpPassword = proto.getHttpPassword();
+    }
+    if (proto.getGroupsCount() > 0) {
+      accountInput.groups = proto.getGroupsList();
+    }
+    return accountInput;
+  }
+
+  @Override
+  public Parser<Entities.AccountInput> getParser() {
+    return Entities.AccountInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
new file mode 100644
index 0000000..0634671
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link ApplyPatchInput} and {@link
+ * com.google.gerrit.proto.Entities.ApplyPatchInput}.
+ */
+@Immutable
+public enum ApplyPatchInputProtoConverter
+    implements ProtoConverter<Entities.ApplyPatchInput, ApplyPatchInput> {
+  INSTANCE;
+
+  @Override
+  public Entities.ApplyPatchInput toProto(ApplyPatchInput applyPatchInput) {
+    Entities.ApplyPatchInput.Builder builder = Entities.ApplyPatchInput.newBuilder();
+    if (applyPatchInput.patch != null) {
+      builder.setPatch(applyPatchInput.patch);
+    }
+    if (applyPatchInput.allowConflicts != null) {
+      builder.setAllowConflicts(applyPatchInput.allowConflicts);
+    }
+    return builder.build();
+  }
+
+  @Override
+  public ApplyPatchInput fromProto(Entities.ApplyPatchInput proto) {
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    if (proto.hasPatch()) {
+      applyPatchInput.patch = proto.getPatch();
+    }
+    if (proto.hasAllowConflicts()) {
+      applyPatchInput.allowConflicts = proto.getAllowConflicts();
+    }
+    return applyPatchInput;
+  }
+
+  @Override
+  public Parser<Entities.ApplyPatchInput> getParser() {
+    return Entities.ApplyPatchInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
index 0d4ec70..909b4d3 100644
--- a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
@@ -17,10 +17,11 @@
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Id;
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum ChangeIdProtoConverter implements ProtoConverter<Entities.Change_Id, Change.Id> {
+public enum ChangeIdProtoConverter implements SafeProtoConverter<Entities.Change_Id, Change.Id> {
   INSTANCE;
 
   @Override
@@ -37,4 +38,14 @@
   public Parser<Entities.Change_Id> getParser() {
     return Entities.Change_Id.parser();
   }
+
+  @Override
+  public Class<Change_Id> getProtoClass() {
+    return Change_Id.class;
+  }
+
+  @Override
+  public Class<Change.Id> getEntityClass() {
+    return Change.Id.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java
new file mode 100644
index 0000000..7270af8
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Proto converter between {@link ChangeInput} and {@link
+ * com.google.gerrit.proto.Entities.ChangeInput}.
+ */
+@Immutable
+public enum ChangeInputProtoConverter implements ProtoConverter<Entities.ChangeInput, ChangeInput> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.MergeInput, MergeInput> mergeInputConverter =
+      MergeInputProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.ApplyPatchInput, ApplyPatchInput> applyPatchInputConverter =
+      ApplyPatchInputProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.AccountInput, AccountInput> accountInputConverter =
+      AccountInputProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.NotifyInfo, NotifyInfo> notifyInfoConverter =
+      NotifyInfoProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.ChangeInput toProto(ChangeInput changeInput) {
+    Entities.ChangeInput.Builder builder = Entities.ChangeInput.newBuilder();
+    if (changeInput.project != null) {
+      builder.setProject(changeInput.project);
+    }
+    if (changeInput.branch != null) {
+      builder.setBranch(changeInput.branch);
+    }
+    if (changeInput.subject != null) {
+      builder.setSubject(changeInput.subject);
+    }
+    if (changeInput.topic != null) {
+      builder.setTopic(changeInput.topic);
+    }
+    if (changeInput.status != null) {
+      builder.setStatus(Entities.ChangeStatus.forNumber(changeInput.status.getValue()));
+    }
+    if (changeInput.isPrivate != null) {
+      builder.setIsPrivate(changeInput.isPrivate);
+    }
+    if (changeInput.workInProgress != null) {
+      builder.setWorkInProgress(changeInput.workInProgress);
+    }
+    if (changeInput.baseChange != null) {
+      builder.setBaseChange(changeInput.baseChange);
+    }
+    if (changeInput.baseCommit != null) {
+      builder.setBaseCommit(changeInput.baseCommit);
+    }
+    if (changeInput.newBranch != null) {
+      builder.setNewBranch(changeInput.newBranch);
+    }
+    if (changeInput.validationOptions != null) {
+      builder.putAllValidationOptions(changeInput.validationOptions);
+    }
+    if (changeInput.customKeyedValues != null) {
+      builder.putAllCustomKeyedValues(changeInput.customKeyedValues);
+    }
+    if (changeInput.merge != null) {
+      builder.setMerge(mergeInputConverter.toProto(changeInput.merge));
+    }
+    if (changeInput.patch != null) {
+      builder.setPatch(applyPatchInputConverter.toProto(changeInput.patch));
+    }
+    if (changeInput.author != null) {
+      builder.setAuthor(accountInputConverter.toProto(changeInput.author));
+    }
+    builder.setNotify(Entities.NotifyHandling.forNumber(changeInput.notify.getValue()));
+
+    List<ListChangesOption> responseFormatOptions = changeInput.responseFormatOptions;
+    if (responseFormatOptions != null) {
+      for (ListChangesOption option : responseFormatOptions) {
+        builder.addResponseFormatOptions(Entities.ListChangesOption.forNumber(option.getValue()));
+      }
+    }
+
+    if (changeInput.notifyDetails != null) {
+      Map<RecipientType, NotifyInfo> notifyDetails = changeInput.notifyDetails;
+      for (Map.Entry<RecipientType, NotifyInfo> entry : notifyDetails.entrySet()) {
+        Entities.RecipientType recipientType =
+            Entities.RecipientType.forNumber(entry.getKey().getValue());
+        builder.putNotifyDetails(
+            recipientType.name(), notifyInfoConverter.toProto(entry.getValue()));
+      }
+    }
+    return builder.build();
+  }
+
+  @Override
+  public ChangeInput fromProto(Entities.ChangeInput proto) {
+    ChangeInput changeInput =
+        new ChangeInput(proto.getProject(), proto.getBranch(), proto.getSubject());
+    if (proto.hasTopic()) {
+      changeInput.topic = proto.getTopic();
+    }
+    if (proto.hasStatus()) {
+      changeInput.status = ChangeStatus.valueOf(proto.getStatus().name());
+    }
+    if (proto.hasIsPrivate()) {
+      changeInput.isPrivate = proto.getIsPrivate();
+    }
+    if (proto.hasWorkInProgress()) {
+      changeInput.workInProgress = proto.getWorkInProgress();
+    }
+    if (proto.hasBaseChange()) {
+      changeInput.baseChange = proto.getBaseChange();
+    }
+    if (proto.hasBaseCommit()) {
+      changeInput.baseCommit = proto.getBaseCommit();
+    }
+    if (proto.hasNewBranch()) {
+      changeInput.newBranch = proto.getNewBranch();
+    }
+    if (proto.getValidationOptionsCount() > 0) {
+      changeInput.validationOptions = proto.getValidationOptionsMap();
+    }
+    if (proto.getCustomKeyedValuesCount() > 0) {
+      changeInput.customKeyedValues = proto.getCustomKeyedValuesMap();
+    }
+    if (proto.hasMerge()) {
+      changeInput.merge = mergeInputConverter.fromProto(proto.getMerge());
+    }
+    if (proto.hasPatch()) {
+      changeInput.patch = applyPatchInputConverter.fromProto(proto.getPatch());
+    }
+    if (proto.hasAuthor()) {
+      changeInput.author = accountInputConverter.fromProto(proto.getAuthor());
+    }
+    if (proto.getResponseFormatOptionsCount() > 0) {
+      changeInput.responseFormatOptions = new ArrayList<>();
+      for (Entities.ListChangesOption option : proto.getResponseFormatOptionsList()) {
+        changeInput.responseFormatOptions.add(ListChangesOption.valueOf(option.name()));
+      }
+    }
+
+    changeInput.notify = NotifyHandling.valueOf(proto.getNotify().name());
+
+    if (proto.getNotifyDetailsCount() > 0) {
+      changeInput.notifyDetails = new HashMap<>();
+      for (Map.Entry<String, Entities.NotifyInfo> entry : proto.getNotifyDetailsMap().entrySet()) {
+        changeInput.notifyDetails.put(
+            RecipientType.valueOf(entry.getKey()), notifyInfoConverter.fromProto(entry.getValue()));
+      }
+    }
+
+    return changeInput;
+  }
+
+  @Override
+  public Parser<Entities.ChangeInput> getParser() {
+    return Entities.ChangeInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
index f3ccdfa..0620c70 100644
--- a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
@@ -17,10 +17,11 @@
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Key;
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum ChangeKeyProtoConverter implements ProtoConverter<Entities.Change_Key, Change.Key> {
+public enum ChangeKeyProtoConverter implements SafeProtoConverter<Entities.Change_Key, Change.Key> {
   INSTANCE;
 
   @Override
@@ -37,4 +38,14 @@
   public Parser<Entities.Change_Key> getParser() {
     return Entities.Change_Key.parser();
   }
+
+  @Override
+  public Class<Change_Key> getProtoClass() {
+    return Change_Key.class;
+  }
+
+  @Override
+  public Class<Change.Key> getEntityClass() {
+    return Change.Key.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
index 3e93c5a..a76ab98 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
@@ -18,11 +18,12 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.ChangeMessage_Key;
 import com.google.protobuf.Parser;
 
 @Immutable
 public enum ChangeMessageKeyProtoConverter
-    implements ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
+    implements SafeProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -45,4 +46,14 @@
   public Parser<Entities.ChangeMessage_Key> getParser() {
     return Entities.ChangeMessage_Key.parser();
   }
+
+  @Override
+  public Class<ChangeMessage_Key> getProtoClass() {
+    return ChangeMessage_Key.class;
+  }
+
+  @Override
+  public Class<ChangeMessage.Key> getEntityClass() {
+    return ChangeMessage.Key.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 3b772d0..93cacaf 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -79,6 +79,10 @@
     if (cherryPickOf != null) {
       builder.setCherryPickOf(patchSetIdConverter.toProto(cherryPickOf));
     }
+    String serverId = change.getServerId();
+    if (serverId != null) {
+      builder.setServerId(serverId);
+    }
     return builder.build();
   }
 
@@ -119,6 +123,9 @@
     if (proto.hasCherryPickOf()) {
       change.setCherryPickOf(patchSetIdConverter.fromProto(proto.getCherryPickOf()));
     }
+    if (proto.hasServerId()) {
+      change.setServerId(proto.getServerId());
+    }
     return change;
   }
 
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
index 6e8c907..316a042 100644
--- a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -35,7 +35,7 @@
  */
 @Immutable
 public enum HumanCommentProtoConverter
-    implements ProtoConverter<Entities.HumanComment, HumanComment> {
+    implements SafeProtoConverter<Entities.HumanComment, HumanComment> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
@@ -142,4 +142,14 @@
   public Parser<Entities.HumanComment> getParser() {
     return Entities.HumanComment.parser();
   }
+
+  @Override
+  public Class<Entities.HumanComment> getProtoClass() {
+    return Entities.HumanComment.class;
+  }
+
+  @Override
+  public Class<HumanComment> getEntityClass() {
+    return HumanComment.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
index a1894ac..e6e1be7f 100644
--- a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum LabelIdProtoConverter implements ProtoConverter<Entities.LabelId, LabelId> {
+public enum LabelIdProtoConverter implements SafeProtoConverter<Entities.LabelId, LabelId> {
   INSTANCE;
 
   @Override
@@ -37,4 +37,14 @@
   public Parser<Entities.LabelId> getParser() {
     return Entities.LabelId.parser();
   }
+
+  @Override
+  public Class<Entities.LabelId> getProtoClass() {
+    return Entities.LabelId.class;
+  }
+
+  @Override
+  public Class<LabelId> getEntityClass() {
+    return LabelId.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/MergeInputProtoConverter.java b/java/com/google/gerrit/entities/converter/MergeInputProtoConverter.java
new file mode 100644
index 0000000..11f78a4
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/MergeInputProtoConverter.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link MergeInput} and {@link
+ * com.google.gerrit.proto.Entities.MergeInput}.
+ */
+@Immutable
+public enum MergeInputProtoConverter implements ProtoConverter<Entities.MergeInput, MergeInput> {
+  INSTANCE;
+
+  @Override
+  public Entities.MergeInput toProto(MergeInput mergeInput) {
+    Entities.MergeInput.Builder builder = Entities.MergeInput.newBuilder();
+    if (mergeInput.source != null) {
+      builder.setSource(mergeInput.source);
+    }
+    if (mergeInput.sourceBranch != null) {
+      builder.setSourceBranch(mergeInput.sourceBranch);
+    }
+    if (mergeInput.strategy != null) {
+      builder.setStrategy(mergeInput.strategy);
+    }
+    builder.setAllowConflicts(mergeInput.allowConflicts);
+    return builder.build();
+  }
+
+  @Override
+  public MergeInput fromProto(Entities.MergeInput proto) {
+    MergeInput mergeInput = new MergeInput();
+    if (proto.hasSource()) {
+      mergeInput.source = proto.getSource();
+    }
+    if (proto.hasSourceBranch()) {
+      mergeInput.sourceBranch = proto.getSourceBranch();
+    }
+    if (proto.hasStrategy()) {
+      mergeInput.strategy = proto.getStrategy();
+    }
+    if (proto.hasAllowConflicts()) {
+      mergeInput.allowConflicts = proto.getAllowConflicts();
+    }
+    return mergeInput;
+  }
+
+  @Override
+  public Parser<Entities.MergeInput> getParser() {
+    return Entities.MergeInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
new file mode 100644
index 0000000..fc963df
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link NotifyInfo} and {@link
+ * com.google.gerrit.proto.Entities.NotifyInfo}.
+ */
+@Immutable
+public enum NotifyInfoProtoConverter
+    implements SafeProtoConverter<Entities.NotifyInfo, NotifyInfo> {
+  INSTANCE;
+
+  @Override
+  public Entities.NotifyInfo toProto(NotifyInfo notifyInfo) {
+    Entities.NotifyInfo.Builder builder = Entities.NotifyInfo.newBuilder();
+    if (notifyInfo.accounts != null) {
+      builder.addAllAccounts(notifyInfo.accounts);
+    }
+    return builder.build();
+  }
+
+  @Override
+  public NotifyInfo fromProto(Entities.NotifyInfo proto) {
+    return new NotifyInfo(proto.getAccountsList());
+  }
+
+  @Override
+  public Parser<Entities.NotifyInfo> getParser() {
+    return Entities.NotifyInfo.parser();
+  }
+
+  @Override
+  public Class<Entities.NotifyInfo> getProtoClass() {
+    return Entities.NotifyInfo.class;
+  }
+
+  @Override
+  public Class<NotifyInfo> getEntityClass() {
+    return NotifyInfo.class;
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
index c7d1714..3ea14e6 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
@@ -20,11 +20,12 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSetApproval_Key;
 import com.google.protobuf.Parser;
 
 @Immutable
 public enum PatchSetApprovalKeyProtoConverter
-    implements ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
+    implements SafeProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
   INSTANCE;
 
   private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -55,4 +56,14 @@
   public Parser<Entities.PatchSetApproval_Key> getParser() {
     return Entities.PatchSetApproval_Key.parser();
   }
+
+  @Override
+  public Class<PatchSetApproval_Key> getProtoClass() {
+    return PatchSetApproval_Key.class;
+  }
+
+  @Override
+  public Class<PatchSetApproval.Key> getEntityClass() {
+    return PatchSetApproval.Key.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
index 60c13f1..f6671cf 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
@@ -18,10 +18,12 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSet_Id;
 import com.google.protobuf.Parser;
 
 @Immutable
-public enum PatchSetIdProtoConverter implements ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
+public enum PatchSetIdProtoConverter
+    implements SafeProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
   INSTANCE;
 
   private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -44,4 +46,14 @@
   public Parser<Entities.PatchSet_Id> getParser() {
     return Entities.PatchSet_Id.parser();
   }
+
+  @Override
+  public Class<PatchSet_Id> getProtoClass() {
+    return PatchSet_Id.class;
+  }
+
+  @Override
+  public Class<PatchSet.Id> getEntityClass() {
+    return PatchSet.Id.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 196deca..22985d9 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -24,7 +24,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 @Immutable
-public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
+public enum PatchSetProtoConverter implements SafeProtoConverter<Entities.PatchSet, PatchSet> {
   INSTANCE;
 
   private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -103,4 +103,14 @@
   public Parser<Entities.PatchSet> getParser() {
     return Entities.PatchSet.parser();
   }
+
+  @Override
+  public Class<Entities.PatchSet> getProtoClass() {
+    return Entities.PatchSet.class;
+  }
+
+  @Override
+  public Class<PatchSet> getEntityClass() {
+    return PatchSet.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
index 6bb0f79..320b8fc 100644
--- a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
@@ -16,12 +16,14 @@
 
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Project_NameKey;
 import com.google.protobuf.Parser;
 
 @Immutable
 public enum ProjectNameKeyProtoConverter
-    implements ProtoConverter<Entities.Project_NameKey, Project.NameKey> {
+    implements SafeProtoConverter<Entities.Project_NameKey, Project.NameKey> {
   INSTANCE;
 
   @Override
@@ -38,4 +40,14 @@
   public Parser<Entities.Project_NameKey> getParser() {
     return Entities.Project_NameKey.parser();
   }
+
+  @Override
+  public Class<Project_NameKey> getProtoClass() {
+    return Project_NameKey.class;
+  }
+
+  @Override
+  public Class<NameKey> getEntityClass() {
+    return NameKey.class;
+  }
 }
diff --git a/java/com/google/gerrit/entities/converter/SafeProtoConverter.java b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
new file mode 100644
index 0000000..f4a66a0
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
@@ -0,0 +1,29 @@
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.protobuf.Message;
+
+/**
+ * An extension to {@link ProtoConverter} that enforces the Entity class and the Proto class to stay
+ * in sync. The enforcement is done by {@link SafeProtoConverterTest}.
+ *
+ * <p>Requirements:
+ *
+ * <ul>
+ *   <li>Implementing classes must be enums with a single value. Please prefer descriptive enum and
+ *       instance names, such as {@code MyTypeConverter::MY_TYPE_CONVERTER}.
+ *   <li>The Java Entity class must be annotated with {@link ConvertibleToProto}.
+ * </ul>
+ *
+ * <p>All safe converters are tested using {@link SafeProtoConverterTest}. Therefore, unless your
+ * Entity class has a {@code defaults()} method, or other methods besides simple getters and
+ * setters, there is no need to explicitly test your safe converter.
+ */
+@Immutable
+public interface SafeProtoConverter<P extends Message, C> extends ProtoConverter<P, C> {
+
+  Class<P> getProtoClass();
+
+  Class<C> getEntityClass();
+}
diff --git a/java/com/google/gerrit/extensions/api/access/GerritPermission.java b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
index 02afbdc..cd80a09 100644
--- a/java/com/google/gerrit/extensions/api/access/GerritPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
@@ -27,6 +27,8 @@
    */
   String describeForException();
 
+  String permissionName();
+
   static String describeEnumValue(Enum<?> value) {
     return value.name().toLowerCase(Locale.US).replace('_', ' ');
   }
diff --git a/java/com/google/gerrit/extensions/api/access/PluginPermission.java b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
index 1dc5cb6..f536b2c 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
@@ -52,6 +52,11 @@
   }
 
   @Override
+  public String permissionName() {
+    return pluginName + "~" + capability;
+  }
+
+  @Override
   public int hashCode() {
     return Objects.hash(pluginName, capability);
   }
diff --git a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
index b3680ea..8fa1e55 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
@@ -54,6 +54,11 @@
   }
 
   @Override
+  public String permissionName() {
+    return pluginName + "~" + permission;
+  }
+
+  @Override
   public int hashCode() {
     return Objects.hash(pluginName, permission);
   }
diff --git a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
index 8273d84..4ad29df 100644
--- a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
+++ b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -31,6 +31,7 @@
   public Boolean canAdd;
   public Boolean canAddTags;
   public Boolean configVisible;
+  public Boolean requireChangeForConfigUpdate;
   public Map<String, GroupInfo> groups;
   public List<WebLinkInfo> configWebLinks;
 }
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index e40f82e..251bb5b 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountStateInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -37,6 +38,8 @@
 
   AccountDetailInfo detail() throws RestApiException;
 
+  AccountStateInfo state() throws RestApiException;
+
   boolean getActive() throws RestApiException;
 
   void setActive(boolean active) throws RestApiException;
@@ -152,6 +155,11 @@
     }
 
     @Override
+    public AccountStateInfo state() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public boolean getActive() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
index 493329c..b9707e9 100644
--- a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
+
 /** Information about a patch to apply. */
 public class ApplyPatchInput {
   /**
@@ -22,4 +24,10 @@
    * <p>Must be compatible with `git diff` output. For example, Gerrit API `Get Patch` output.
    */
   public String patch;
+
+  /**
+   * If {@code true}, the operation will succeed if a conflict is detected. Conflict markers will be
+   * added to the conflicting files.
+   */
+  @Nullable public Boolean allowConflicts;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 1c83bc2..dec3125 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -132,13 +132,21 @@
     setReadyForReview(null);
   }
 
-  /** Create a new change that reverts this change. */
+  /**
+   * Create a new change that reverts this change.
+   *
+   * @see Changes#id(String, int)
+   */
   @CanIgnoreReturnValue
   default ChangeApi revert() throws RestApiException {
     return revert(new RevertInput());
   }
 
-  /** Create a new change that reverts this change. */
+  /**
+   * Create a new change that reverts this change.
+   *
+   * @see Changes#id(String, int)
+   */
   @CanIgnoreReturnValue
   ChangeApi revert(RevertInput in) throws RestApiException;
 
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
index 2fd8a07..6d99ded 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -91,6 +91,14 @@
   void rebase() throws RestApiException;
 
   /**
+   * Rebases the change edit on top of the latest patch set of this change.
+   *
+   * @param input params for rebasing the change edit
+   * @throws RestApiException if the change edit couldn't be rebased or a change edit wasn't present
+   */
+  EditInfo rebase(RebaseChangeEditInput input) throws RestApiException;
+
+  /**
    * Publishes the change edit using default settings. See {@link #publish(PublishChangeEditInput)}
    * for more details.
    *
@@ -238,6 +246,11 @@
     }
 
     @Override
+    public EditInfo rebase(RebaseChangeEditInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void publish() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index 5e3d08c..605a92e 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -32,14 +32,10 @@
   /**
    * Look up a change by numeric ID.
    *
-   * <p><strong>Note:</strong> This method eagerly reads the change. Methods that mutate the change
-   * do not necessarily re-read the change. Therefore, calling a getter method on an instance after
-   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
-   * is not recommended to store references to {@code ChangeApi} instances. Also note that the
-   * change numeric id without a project name parameter may fail to identify a unique change
-   * element, because the potential conflict with other changes imported from Gerrit instances with
-   * a different Server-Id.
+   * <p><strong>Note:</strong> Change number is not guaranteed to unambiguously identify a change.
    *
+   * @see #id(String, int)
+   * @deprecated in favor of {@link #id(String, int)}
    * @param id change number.
    * @return API for accessing the change.
    * @throws RestApiException if an error occurred.
@@ -50,7 +46,7 @@
   /**
    * Look up a change by string ID.
    *
-   * @see #id(int)
+   * @see #id(String, int)
    * @param id any identifier supported by the REST API, including change number, Change-Id, or
    *     project~branch~Change-Id triplet.
    * @return API for accessing the change.
@@ -61,16 +57,23 @@
   /**
    * Look up a change by project, branch, and change ID.
    *
-   * @see #id(int)
+   * @see #id(String, int)
    */
   ChangeApi id(String project, String branch, String id) throws RestApiException;
 
   /**
    * Look up a change by project and numeric ID.
    *
+   * <p><strong>Note:</strong> This method eagerly reads the change. Methods that mutate the change
+   * do not necessarily re-read the change. Therefore, calling a getter method on an instance after
+   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
+   * is not recommended to store references to {@code ChangeApi} instances. Also note that the
+   * change numeric id without a project name parameter may fail to identify a unique change
+   * element, because the potential conflict with other changes imported from Gerrit instances with
+   * a different Server-Id.
+   *
    * @param project project name.
    * @param id change number.
-   * @see #id(int)
    */
   ChangeApi id(String project, int id) throws RestApiException;
 
diff --git a/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java b/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
index 98ef31c..f8a769c 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
@@ -15,8 +15,18 @@
 package com.google.gerrit.extensions.api.changes;
 
 public enum NotifyHandling {
-  NONE,
-  OWNER,
-  OWNER_REVIEWERS,
-  ALL
+  NONE(0),
+  OWNER(1),
+  OWNER_REVIEWERS(2),
+  ALL(3);
+
+  private final int value;
+
+  NotifyHandling(int v) {
+    this.value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
index dd29635..21bf886 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.List;
 import java.util.Objects;
 
 /** Detailed information about who should be notified about an update. */
+@ConvertibleToProto
 public class NotifyInfo {
   public List<String> accounts;
 
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseChangeEditInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseChangeEditInput.java
new file mode 100644
index 0000000..4eb0ebb
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseChangeEditInput.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+public class RebaseChangeEditInput {
+  /**
+   * Whether the rebase should succeed if there are conflicts.
+   *
+   * <p>If there are conflicts the file contents of the rebased change contain git conflict markers
+   * to indicate the conflicts.
+   */
+  public boolean allowConflicts;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RecipientType.java b/java/com/google/gerrit/extensions/api/changes/RecipientType.java
index 3ddc597..91b2706 100644
--- a/java/com/google/gerrit/extensions/api/changes/RecipientType.java
+++ b/java/com/google/gerrit/extensions/api/changes/RecipientType.java
@@ -15,7 +15,17 @@
 package com.google.gerrit.extensions.api.changes;
 
 public enum RecipientType {
-  TO,
-  CC,
-  BCC
+  TO(0),
+  CC(1),
+  BCC(2);
+
+  private final int value;
+
+  RecipientType(int v) {
+    this.value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/config/ExperimentApi.java b/java/com/google/gerrit/extensions/api/config/ExperimentApi.java
new file mode 100644
index 0000000..fb30884
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/config/ExperimentApi.java
@@ -0,0 +1,30 @@
+// Copyright (C) 20124 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.config;
+
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface ExperimentApi {
+  ExperimentInfo get() throws RestApiException;
+
+  class NotImplemented implements ExperimentApi {
+    @Override
+    public ExperimentInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/config/Server.java b/java/com/google/gerrit/extensions/api/config/Server.java
index 0fe09c6..26806d1 100644
--- a/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/java/com/google/gerrit/extensions/api/config/Server.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ExperimentInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -49,6 +51,25 @@
 
   List<TopMenu.MenuEntry> topMenus() throws RestApiException;
 
+  ExperimentApi experiment(String name) throws RestApiException;
+
+  ListExperimentsRequest listExperiments() throws RestApiException;
+
+  abstract class ListExperimentsRequest {
+    private boolean enabledOnly;
+
+    public abstract ImmutableMap<String, ExperimentInfo> get() throws RestApiException;
+
+    public ListExperimentsRequest enabledOnly() {
+      enabledOnly = true;
+      return this;
+    }
+
+    public boolean getEnabledOnly() {
+      return enabledOnly;
+    }
+  }
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -106,5 +127,15 @@
     public List<TopMenu.MenuEntry> topMenus() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ExperimentApi experiment(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListExperimentsRequest listExperiments() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 906fc4c..805e769 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -40,4 +40,5 @@
   public ProjectState state;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
   public Map<String, CommentLinkInput> commentLinks;
+  public String commitMessage;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 1169364..58fd93a8 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -20,8 +20,10 @@
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
 import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -58,6 +60,9 @@
   @CanIgnoreReturnValue
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
+  ChangeInfo configReview(ConfigInput in) throws RestApiException;
+
   Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
       throws RestApiException;
 
@@ -72,9 +77,11 @@
   abstract class ListRefsRequest<T extends RefInfo> {
     protected int limit;
     protected int start;
+    protected boolean descendingOrder;
     protected String substring;
     protected String regex;
     protected String nextPageToken;
+    protected ListTagSortOption sortBy = ListTagSortOption.REF;
 
     public abstract List<T> get() throws RestApiException;
 
@@ -88,6 +95,16 @@
       return this;
     }
 
+    public ListRefsRequest<T> withDescendingOrder(boolean descendingOrder) {
+      this.descendingOrder = descendingOrder;
+      return this;
+    }
+
+    public ListRefsRequest<T> withSortBy(ListTagSortOption sortBy) {
+      this.sortBy = sortBy;
+      return this;
+    }
+
     public ListRefsRequest<T> withNextPageToken(String token) {
       this.nextPageToken = token;
       return this;
@@ -111,6 +128,14 @@
       return start;
     }
 
+    public boolean getDescendingOrder() {
+      return descendingOrder;
+    }
+
+    public ListTagSortOption getSortBy() {
+      return sortBy;
+    }
+
     public String getNextPageToken() {
       return nextPageToken;
     }
@@ -263,6 +288,25 @@
    */
   void labels(BatchLabelInput input) throws RestApiException;
 
+  /** Same as {@link #labels(BatchLabelInput)}, but creates a change with required updates. */
+  @CanIgnoreReturnValue
+  ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException;
+
+  /**
+   * Adds, updates and deletes submit requirements definitions in a batch.
+   *
+   * @param input input that describes additions, updates and deletions of submit requirements
+   */
+  void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException;
+
+  /**
+   * Creates a change with required submit requirements updates.
+   *
+   * <p>See {@link #submitRequirements(BatchSubmitRequirementInput)} for details
+   */
+  @CanIgnoreReturnValue
+  ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -324,6 +368,11 @@
     }
 
     @Override
+    public ChangeInfo configReview(ConfigInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
         throws RestApiException {
       throw new NotImplementedException();
@@ -468,5 +517,21 @@
     public void labels(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
index 6240bba..6c6069d 100644
--- a/java/com/google/gerrit/extensions/client/ChangeKind.java
+++ b/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -22,6 +22,12 @@
   /** Conflict-free merge between the new parent and the prior patch set. */
   TRIVIAL_REBASE,
 
+  /**
+   * Conflict-free merge between the new parent and the prior patch set, accompanied with a change
+   * to commit message.
+   */
+  TRIVIAL_REBASE_WITH_MESSAGE_UPDATE,
+
   /** Conflict-free change of first (left) parent of a merge commit. */
   MERGE_FIRST_PARENT_UPDATE,
 
@@ -39,6 +45,8 @@
         return true;
       case TRIVIAL_REBASE:
         return isTrivialRebase();
+      case TRIVIAL_REBASE_WITH_MESSAGE_UPDATE:
+        return isTrivialRebaseWithMessageUpdate();
       case MERGE_FIRST_PARENT_UPDATE:
         return isMergeFirstParentUpdate(isMerge);
       case NO_CHANGE:
@@ -59,6 +67,14 @@
     return this == NO_CHANGE || this == TRIVIAL_REBASE;
   }
 
+  public boolean isTrivialRebaseWithMessageUpdate() {
+    // TRIVIAL_REBASE is more strict condition and hence matched as well
+    return this == NO_CHANGE
+        || this == NO_CODE_CHANGE
+        || this == TRIVIAL_REBASE
+        || this == TRIVIAL_REBASE_WITH_MESSAGE_UPDATE;
+  }
+
   public boolean isMergeFirstParentUpdate(boolean isMerge) {
     if (!isMerge) {
       return false;
diff --git a/java/com/google/gerrit/extensions/client/ChangeStatus.java b/java/com/google/gerrit/extensions/client/ChangeStatus.java
index 83d5bd2..4111dc4 100644
--- a/java/com/google/gerrit/extensions/client/ChangeStatus.java
+++ b/java/com/google/gerrit/extensions/client/ChangeStatus.java
@@ -31,7 +31,7 @@
    *   <li>{@link #ABANDONED} - when the Abandon action is used.
    * </ul>
    */
-  NEW,
+  NEW(0),
 
   /**
    * Change is closed, and submitted to its destination branch.
@@ -39,7 +39,7 @@
    * <p>Once a change has been merged, it cannot be further modified by adding a replacement patch
    * set. Draft comments however may be published, supporting a post-submit review.
    */
-  MERGED,
+  MERGED(1),
 
   /**
    * Change is closed, but was not submitted to its destination branch.
@@ -54,5 +54,15 @@
    *   <li>{@link #NEW} - when the Restore action is used.
    * </ul>
    */
-  ABANDONED
+  ABANDONED(2);
+
+  private final int value;
+
+  ChangeStatus(int v) {
+    this.value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 109afd6..ad494cb 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.client;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.Objects;
 
+@ConvertibleToProto
 public class DiffPreferencesInfo {
 
   /** Default number of lines of context. */
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 0a3ec0a..5da211e 100644
--- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.extensions.client;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.Objects;
 
 /* This class is stored in Git config file. */
+@ConvertibleToProto
 public class EditPreferencesInfo {
   public Integer tabSize;
   public Integer lineLength;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 1ed9793..44fc4d5 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.extensions.client;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
 import java.util.List;
 import java.util.Objects;
 
 /** Preferences about a single user. */
+@ConvertibleToProto
 public class GeneralPreferencesInfo {
 
   /** Default number of items to display per page. */
@@ -143,6 +145,8 @@
   public List<String> changeTable;
   public Boolean allowBrowserNotifications;
   public Boolean allowSuggestCodeWhileCommenting;
+  public Boolean allowAutocompletingComments;
+
   /**
    * The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
    * sidebars should be closed.
@@ -214,6 +218,9 @@
         && Objects.equals(this.my, other.my)
         && Objects.equals(this.changeTable, other.changeTable)
         && Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications)
+        && Objects.equals(
+            this.allowSuggestCodeWhileCommenting, other.allowSuggestCodeWhileCommenting)
+        && Objects.equals(this.allowAutocompletingComments, other.allowAutocompletingComments)
         && Objects.equals(this.diffPageSidebar, other.diffPageSidebar);
   }
 
@@ -242,6 +249,8 @@
         my,
         changeTable,
         allowBrowserNotifications,
+        allowSuggestCodeWhileCommenting,
+        allowAutocompletingComments,
         diffPageSidebar);
   }
 
@@ -270,6 +279,8 @@
         .add("my", my)
         .add("changeTable", changeTable)
         .add("allowBrowserNotifications", allowBrowserNotifications)
+        .add("allowSuggestCodeWhileCommenting", allowSuggestCodeWhileCommenting)
+        .add("allowAutocompletingComments", allowAutocompletingComments)
         .add("diffPageSidebar", diffPageSidebar)
         .toString();
   }
@@ -296,8 +307,9 @@
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
     p.allowBrowserNotifications = true;
-    p.diffPageSidebar = "NONE";
     p.allowSuggestCodeWhileCommenting = true;
+    p.allowAutocompletingComments = true;
+    p.diffPageSidebar = "NONE";
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/AbstractBatchInput.java b/java/com/google/gerrit/extensions/common/AbstractBatchInput.java
new file mode 100644
index 0000000..b872b18
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AbstractBatchInput.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+/** Input for the REST API that describes additions, updates and deletions items in a collection. */
+public abstract class AbstractBatchInput<T> {
+  public String commitMessage;
+  public List<String> delete;
+  public List<T> create;
+  public Map<String, T> update;
+}
diff --git a/java/com/google/gerrit/extensions/common/AccountInfo.java b/java/com/google/gerrit/extensions/common/AccountInfo.java
index 4701e86..e19df78 100644
--- a/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -102,6 +102,7 @@
           && Objects.equals(avatars, accountInfo.avatars)
           && Objects.equals(_moreAccounts, accountInfo._moreAccounts)
           && Objects.equals(status, accountInfo.status)
+          && Objects.equals(inactive, accountInfo.inactive)
           && Objects.equals(tags, accountInfo.tags);
     }
     return false;
@@ -115,6 +116,7 @@
         .add("displayname", displayName)
         .add("email", email)
         .add("username", username)
+        .add("inactive", inactive)
         .add("tags", tags)
         .toString();
   }
@@ -131,6 +133,7 @@
         avatars,
         _moreAccounts,
         status,
+        inactive,
         tags);
   }
 
diff --git a/java/com/google/gerrit/extensions/common/AccountStateInfo.java b/java/com/google/gerrit/extensions/common/AccountStateInfo.java
new file mode 100644
index 0000000..b0e88ce
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AccountStateInfo.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Representation of an account state in the REST API.
+ *
+ * <p>This class determines the JSON format of account states in the REST API.
+ */
+public class AccountStateInfo {
+  /** The account details. */
+  public AccountDetailInfo account;
+
+  /** The global capabilities of the account. */
+  public Map<String, Object> capabilities;
+
+  /** The groups that contain the account as a member. */
+  public List<GroupInfo> groups;
+
+  /** The external IDs of the account. */
+  public List<AccountExternalIdInfo> externalIds;
+
+  /** Account metadata populated by plugins. */
+  public List<MetadataInfo> metadata;
+}
diff --git a/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
index cd28d83..7d48fce 100644
--- a/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
+++ b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
@@ -24,4 +24,6 @@
   public ApplyProvidedFixInput() {}
 
   public List<FixReplacementInfo> fixReplacementInfos;
+
+  public Integer originalPatchsetForFix;
 }
diff --git a/java/com/google/gerrit/extensions/common/BatchLabelInput.java b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
index eb4c581..aa91314 100644
--- a/java/com/google/gerrit/extensions/common/BatchLabelInput.java
+++ b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
@@ -14,13 +14,5 @@
 
 package com.google.gerrit.extensions.common;
 
-import java.util.List;
-import java.util.Map;
-
 /** Input for the REST API that describes additions, updates and deletions of label definitions. */
-public class BatchLabelInput {
-  public String commitMessage;
-  public List<String> delete;
-  public List<LabelDefinitionInput> create;
-  public Map<String, LabelDefinitionInput> update;
-}
+public class BatchLabelInput extends AbstractBatchInput<LabelDefinitionInput> {}
diff --git a/java/com/google/gerrit/extensions/restapi/ETagView.java b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
similarity index 64%
rename from java/com/google/gerrit/extensions/restapi/ETagView.java
rename to java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
index 9ac1706..8a5f30d 100644
--- a/java/com/google/gerrit/extensions/restapi/ETagView.java
+++ b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2024 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.extensions.restapi;
+package com.google.gerrit.extensions.common;
 
-/** A view which may change, although the underlying resource did not change */
-public interface ETagView<R extends RestResource> extends RestReadView<R> {
-  String getETag(R rsrc);
-}
+/**
+ * Input for the REST API that describes additions, updates and deletions of submit requirements.
+ */
+public class BatchSubmitRequirementInput extends AbstractBatchInput<SubmitRequirementInput> {}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index a2e2e8f..1576e68 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -93,6 +93,7 @@
    * <p>Only set if this change info is returned in response to a request that creates a new change
    * or patch set and conflicts are allowed. In particular this field is only populated if the
    * change info is returned by one of the following REST endpoints: {@link
+   * com.google.gerrit.server.restapi.change.ApplyPatch},{@link
    * com.google.gerrit.server.restapi.change.CreateChange}, {@link
    * com.google.gerrit.server.restapi.change.CreateMergePatchSet}, {@link
    * com.google.gerrit.server.restapi.change.CherryPick}, {@link
@@ -102,6 +103,7 @@
   public Boolean containsGitConflicts;
 
   public Integer _number;
+  public Integer virtualIdNumber;
 
   public AccountInfo owner;
 
@@ -115,6 +117,7 @@
   public Collection<ReviewerUpdateInfo> reviewerUpdates;
   public Collection<ChangeMessageInfo> messages;
 
+  public Integer currentRevisionNumber;
   public String currentRevision;
   public Map<String, RevisionInfo> revisions;
   public Boolean _moreChanges;
diff --git a/java/com/google/gerrit/extensions/common/EditInfo.java b/java/com/google/gerrit/extensions/common/EditInfo.java
index 0cd5af3..9d170de 100644
--- a/java/com/google/gerrit/extensions/common/EditInfo.java
+++ b/java/com/google/gerrit/extensions/common/EditInfo.java
@@ -23,4 +23,16 @@
   public String ref;
   public Map<String, FetchInfo> fetch;
   public Map<String, FileInfo> files;
+
+  /**
+   * Whether the change edit contains conflicts.
+   *
+   * <p>If {@code true}, some of the file contents of the change contain git conflict markers to
+   * indicate the conflicts.
+   *
+   * <p>Only set if this edit info is returned in response to a request that rebases the change edit
+   * (see {@link com.google.gerrit.server.restapi.change.RebaseChangeEdit}) and conflicts are
+   * allowed.
+   */
+  public Boolean containsGitConflicts;
 }
diff --git a/java/com/google/gerrit/server/change/testing/package-info.java b/java/com/google/gerrit/extensions/common/ExperimentInfo.java
similarity index 73%
copy from java/com/google/gerrit/server/change/testing/package-info.java
copy to java/com/google/gerrit/extensions/common/ExperimentInfo.java
index 3cd4da3..0cf5c9d 100644
--- a/java/com/google/gerrit/server/change/testing/package-info.java
+++ b/java/com/google/gerrit/extensions/common/ExperimentInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 The Android Open Source Project
+// Copyright (C) 2034 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,7 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-@CheckReturnValue
-package com.google.gerrit.server.change.testing;
+package com.google.gerrit.extensions.common;
 
-import com.google.errorprone.annotations.CheckReturnValue;
+public class ExperimentInfo {
+  /** Whether the experiment is enabled. */
+  public Boolean enabled;
+}
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 547e606..fd682c1 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -25,4 +25,5 @@
   public String primaryWeblinkName;
   public String instanceId;
   public String defaultBranch;
+  public Boolean projectStatePredicateEnabled;
 }
diff --git a/java/com/google/gerrit/server/change/testing/package-info.java b/java/com/google/gerrit/extensions/common/ListTagSortOption.java
similarity index 75%
rename from java/com/google/gerrit/server/change/testing/package-info.java
rename to java/com/google/gerrit/extensions/common/ListTagSortOption.java
index 3cd4da3..2140924 100644
--- a/java/com/google/gerrit/server/change/testing/package-info.java
+++ b/java/com/google/gerrit/extensions/common/ListTagSortOption.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 The Android Open Source Project
+// Copyright (C) 2024 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,7 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-@CheckReturnValue
-package com.google.gerrit.server.change.testing;
+package com.google.gerrit.extensions.common;
 
-import com.google.errorprone.annotations.CheckReturnValue;
+public enum ListTagSortOption {
+  REF,
+  CREATION_TIME,
+}
diff --git a/java/com/google/gerrit/extensions/common/MetadataInfo.java b/java/com/google/gerrit/extensions/common/MetadataInfo.java
new file mode 100644
index 0000000..2213191
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/MetadataInfo.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.Nullable;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Metadata populated by plugins, see {code com.google.gerrit.server.account.AccountStateProvider}
+ * and {code com.google.gerrit.server.ServerStateProvider}.
+ */
+public class MetadataInfo {
+  /**
+   * The metadata name.
+   *
+   * <p>Not guaranteed to be unique, e.g. multiple metadata entries with the same name may be
+   * returned.
+   */
+  public String name;
+
+  /** The metadata value. May be unset. */
+  @Nullable public String value;
+
+  /** A description of the metadata. May be unset. */
+  @Nullable public String description;
+
+  /** Web links. May be unset. */
+  @Nullable public List<WebLinkInfo> webLinks;
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("name", name)
+        .add("value", value)
+        .add("description", description)
+        .add("webLinks", webLinks)
+        .toString();
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, value, description, webLinks);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof MetadataInfo) {
+      MetadataInfo metadata = (MetadataInfo) o;
+      return Objects.equals(name, metadata.name)
+          && Objects.equals(value, metadata.value)
+          && Objects.equals(description, metadata.description)
+          && Objects.equals(webLinks, metadata.webLinks);
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ServerInfo.java b/java/com/google/gerrit/extensions/common/ServerInfo.java
index ce65240..0a3b6ee 100644
--- a/java/com/google/gerrit/extensions/common/ServerInfo.java
+++ b/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -31,4 +31,7 @@
   public ReceiveInfo receive;
   public String defaultTheme;
   public List<String> submitRequirementDashboardColumns;
+
+  /** Server metadata populated by plugins. */
+  public List<MetadataInfo> metadata;
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
index 0d1e82a..3cdbe44 100644
--- a/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/EditInfoSubject.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.commits;
 import static com.google.gerrit.truth.MapSubject.mapEntries;
 
+import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
@@ -62,4 +63,10 @@
     isNotNull();
     return check("files").about(mapEntries()).that(editInfo.files);
   }
+
+  public BooleanSubject containsGitConflicts() {
+    isNotNull();
+    return check("containsGitConflicts")
+        .that(editInfo.containsGitConflicts != null ? editInfo.containsGitConflicts : false);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 4464af7..4cc22f9 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.registration;
 
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
+import com.google.inject.BindingAnnotation;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
@@ -25,6 +28,9 @@
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.util.Providers;
 import com.google.inject.util.Types;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
 import java.util.concurrent.atomic.AtomicReference;
 
 /**
@@ -36,6 +42,15 @@
  * exception is thrown.
  */
 public class DynamicItem<T> {
+
+  /** Annotate a DynamicItem to be final and being bound at most once. */
+  @Target({ElementType.TYPE})
+  @Retention(RUNTIME)
+  @BindingAnnotation
+  public @interface Final {
+    String implementedByPlugin() default "";
+  }
+
   /**
    * Declare a singleton {@code DynamicItem<T>} with a binder.
    *
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 9925a66..0c2691a 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -122,7 +122,7 @@
    */
   public static <T> LinkedBindingBuilder<T> bind(Binder binder, Class<T> type, Named name) {
     binder.disableCircularProxies();
-    return bind(binder, TypeLiteral.get(type));
+    return bind(binder, TypeLiteral.get(type), name);
   }
 
   /**
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index 5b528cb..982ff98 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
+import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.ParameterizedType;
 import java.util.ArrayList;
@@ -97,6 +99,26 @@
         DynamicItem<Object> item = (DynamicItem<Object>) e.getValue();
 
         for (Binding<Object> b : bindings(src, type)) {
+          Class<? super Object> rawType = type.getRawType();
+          DynamicItem.Final annotation = rawType.getAnnotation(DynamicItem.Final.class);
+          if (annotation != null) {
+            Object existingBinding = item.get();
+            if (existingBinding != null) {
+              throw new ProvisionException(
+                  String.format(
+                      "Attempting to bind a @DynamicItem.Final %s twice: it was already bound to %s and tried to bind again to %s",
+                      rawType.getName(), existingBinding, b));
+            }
+
+            String implementedByPlugin = annotation.implementedByPlugin();
+            if (!Strings.isNullOrEmpty(implementedByPlugin)
+                && !implementedByPlugin.equals(pluginName)) {
+              throw new ProvisionException(
+                  String.format(
+                      "Attempting to bind a @DynamicItem.Final %s to unexpected plugin: it was supposed to be bound to %s plugin but tried bind to %s plugin",
+                      rawType.getName(), implementedByPlugin, pluginName));
+            }
+          }
           handles.add(item.set(b.getKey(), b.getProvider(), pluginName));
         }
       }
diff --git a/java/com/google/gerrit/extensions/restapi/RestResource.java b/java/com/google/gerrit/extensions/restapi/RestResource.java
index 3c8144a..30916bc 100644
--- a/java/com/google/gerrit/extensions/restapi/RestResource.java
+++ b/java/com/google/gerrit/extensions/restapi/RestResource.java
@@ -29,9 +29,4 @@
     /** Returns time for the Last-Modified header. HTTP truncates the header value to seconds. */
     Timestamp getLastModified();
   }
-
-  /** A resource with an ETag. */
-  public interface HasETag {
-    String getETag();
-  }
 }
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 2f8f7e9..8938fc9 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.gpg;
 
-import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.BAD;
 import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.OK;
 import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.TRUSTED;
@@ -306,8 +305,7 @@
         // signature.
         logger.atInfo().log(
             "Key %s is revoked by %s, which is not in the store. Assuming revocation is valid.",
-            lazy(() -> Fingerprint.toString(key.getFingerprint())),
-            lazy(() -> Fingerprint.toString(rfp)));
+            Fingerprint.toString(key.getFingerprint()), Fingerprint.toString(rfp));
         problems.add(reasonToString(getRevocationReason(revocation)));
         continue;
       }
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 9625039..4ccd05a 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -269,6 +269,7 @@
     outCookie.setPath(path);
     outCookie.setMaxAge(ageSeconds);
     outCookie.setSecure(authConfig.getCookieSecure());
+    outCookie.setHttpOnly(authConfig.getCookieHttpOnly());
     response.addCookie(outCookie);
   }
 
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index d9f1c09..ca937fd 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.httpd;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -24,6 +28,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Optional;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -54,15 +59,15 @@
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String uriPath = req.getPathInfo();
-    // Check if we are processing a comment url, like "/c/1/comment/ff3303fd_8341647b/".
-    int commentIdx = uriPath.indexOf("/comment");
-    String idString = commentIdx == -1 ? uriPath : uriPath.substring(0, commentIdx);
 
-    if (idString.endsWith("/")) {
-      idString = idString.substring(0, idString.length() - 1);
-    }
+    ImmutableList<String> uriSegments =
+        Arrays.stream(uriPath.split("/", 2)).collect(toImmutableList());
+
+    String idString = uriSegments.get(0);
+    String finalSegment = (uriSegments.size() > 1) ? uriSegments.get(1) : null;
+
     Optional<Change.Id> id = Change.Id.tryParse(idString);
-    if (!id.isPresent()) {
+    if (id.isEmpty()) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
     }
@@ -78,10 +83,10 @@
     }
     String path =
         PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
-    if (commentIdx > -1) {
-      // path already contain a trailing /, hence we start from "commentIdx + 1"
-      path = path + uriPath.substring(commentIdx + 1);
+    if (finalSegment != null) {
+      path += finalSegment;
     }
-    UrlModule.toGerrit(path, req, rsp);
+    String queryString = Strings.emptyToNull(req.getQueryString());
+    UrlModule.toGerrit(path + (queryString != null ? "?" + queryString : ""), req, rsp);
   }
 }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 7a100c7..f62fbe8 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -45,6 +45,9 @@
 class UrlModule extends ServletModule {
   private final AuthConfig authConfig;
 
+  private static final String CHANGE_NUMBER_REGEX = "(?:/c)?/([1-9][0-9]*)";
+  private static final String PATCH_SET_REGEX = "([1-9][0-9]*(\\.\\.[1-9][0-9]*)?)";
+
   UrlModule(AuthConfig authConfig) {
     this.authConfig = authConfig;
   }
@@ -72,8 +75,12 @@
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
-    serveRegex("^(?:/c)?/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
-    serveRegex("^(?:/c)?/([1-9][0-9]*)/comment/\\w+/?$").with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "(/" + PATCH_SET_REGEX + ")?/?$")
+        .with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "/" + PATCH_SET_REGEX + "?/[^+]+$")
+        .with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "/comment/\\w+/?$")
+        .with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index ba88617..c8dab81 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -45,7 +45,7 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.pgm.util.LogFileManager.LogFileManagerModule;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
@@ -96,7 +96,7 @@
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
-import com.google.gerrit.server.project.DefaultProjectNameLockManager.DefaultProjectNameLockManagerModule;
+import com.google.gerrit.server.project.DefaultLockManager.DefaultLockManagerModule;
 import com.google.gerrit.server.restapi.RestApiModule;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore.JdbcAccountPatchReviewStoreModule;
 import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
@@ -302,7 +302,7 @@
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressorModule());
+    modules.add(new LogFileManagerModule());
     modules.add(new EventBrokerModule());
     modules.add(new JdbcAccountPatchReviewStoreModule(config));
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
@@ -362,7 +362,7 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
+            bind(GerritOptions.class).toInstance(GerritOptions.DEFAULT);
             bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
           }
         });
@@ -370,7 +370,7 @@
     modules.add(new AttentionSetOwnerAdderModule());
     modules.add(new ChangeCleanupRunnerModule());
     modules.add(new AccountDeactivatorModule());
-    modules.add(new DefaultProjectNameLockManagerModule());
+    modules.add(new DefaultLockManagerModule());
     modules.add(new ExternalIdCaseSensitivityMigrator.ExternalIdCaseSensitivityMigratorModule());
     return dbInjector.createChildInjector(
         ModuleOverloader.override(
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 9b8f4c6..e17a534 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -353,7 +353,7 @@
   }
 
   private boolean isOriginAllowed(String origin) {
-    return allowOrigin == null || allowOrigin.matcher(origin).matches();
+    return allowOrigin != null && allowOrigin.matcher(origin).matches();
   }
 
   private boolean hasUpToDateCachedResource(Resource cachedResource, long lastUpdateTime) {
diff --git a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
index 1e043b1..1316066 100644
--- a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -22,6 +22,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.io.PrintWriter;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -52,8 +53,9 @@
         res.setContentType("image/svg+xml");
         res.setCharacterEncoding(UTF_8.name());
         res.setStatus(HttpServletResponse.SC_OK);
-        res.getWriter().write(responseToClient);
-        res.getWriter().flush();
+        PrintWriter writer = res.getWriter();
+        writer.write(responseToClient);
+        writer.flush();
       } else {
         res.setContentLength(0);
         res.setStatus(HttpServletResponse.SC_NO_CONTENT);
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 77cbe5b..bb21662 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil.RequestedPage;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gson.Gson;
@@ -43,6 +45,7 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.regex.Matcher;
 
 /** Helper for generating parts of {@code index.html}. */
 @UsedAt(Project.GOOGLE)
@@ -50,6 +53,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
    * rendering the soy template.
@@ -68,7 +72,7 @@
     data.putAll(
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
-        .putAll(dynamicTemplateData(gerritApi, requestedURL));
+        .putAll(dynamicTemplateData(gerritApi, requestedURL, canonicalURL));
     Set<String> enabledExperiments = new HashSet<>();
     enabledExperiments.addAll(experimentFeatures.getEnabledExperimentFeatures());
     // Add all experiments enabled through url
@@ -79,23 +83,61 @@
     return data.build();
   }
 
+  /**
+   * Returns the basePatchNum that was specified in the URL when present. If no basePatchNum is
+   * specified then it points to PARENT which is represented by 0
+   */
+  public static Integer computeBasePatchNum(@Nullable String requestedPath) {
+    if (requestedPath == null) {
+      return 0;
+    }
+    Matcher matcher = IndexPreloadingUtil.CHANGE_URL_PATTERN.matcher(requestedPath);
+    String basePatchNum = null;
+    if (matcher.matches()) {
+      basePatchNum = matcher.group("basePatchNum");
+    }
+    if (basePatchNum == null) {
+      return 0; // No match is found
+    }
+    Integer basePatchNumInt = Ints.tryParse(basePatchNum);
+    if (basePatchNumInt == null) {
+      return 0; // tryParse was unable to parse
+    }
+    return basePatchNumInt;
+  }
+
   /** Returns dynamic parameters of {@code index.html}. */
   public static ImmutableMap<String, Object> dynamicTemplateData(
-      GerritApi gerritApi, String requestedURL) throws RestApiException, URISyntaxException {
+      GerritApi gerritApi, String requestedURL, String canonicalURL)
+      throws RestApiException, URISyntaxException {
     ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
     Map<String, SanitizedContent> initialData = new HashMap<>();
     Server serverApi = gerritApi.config().server();
-    initialData.put("\"/config/server/info\"", serializeObject(GSON, serverApi.getInfo()));
-    initialData.put("\"/config/server/version\"", serializeObject(GSON, serverApi.getVersion()));
-    initialData.put("\"/config/server/top-menus\"", serializeObject(GSON, serverApi.topMenus()));
+    initialData.put(
+        addCanonicalUrl("/config/server/info", canonicalURL),
+        serializeObject(GSON, serverApi.getInfo()));
+    initialData.put(
+        addCanonicalUrl("/config/server/version", canonicalURL),
+        serializeObject(GSON, serverApi.getVersion()));
+    initialData.put(
+        addCanonicalUrl("/config/server/top-menus", canonicalURL),
+        serializeObject(GSON, serverApi.topMenus()));
 
     String requestedPath = IndexPreloadingUtil.getPath(requestedURL);
     IndexPreloadingUtil.RequestedPage page = IndexPreloadingUtil.parseRequestedPage(requestedPath);
+    Integer basePatchNum = computeBasePatchNum(requestedPath);
     switch (page) {
       case CHANGE:
       case DIFF:
-        data.put(
-            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
+        if (basePatchNum.equals(0)) {
+          data.put(
+              "defaultChangeDetailHex",
+              ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITHOUT_PARENTS));
+        } else {
+          data.put(
+              "defaultChangeDetailHex",
+              ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITH_PARENTS));
+        }
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
@@ -103,21 +145,25 @@
         break;
       case PROFILE:
       case DASHBOARD:
-        // Dashboard is preloaded queries are added later when we check user is authenticated.
+        // Dashboard is preloaded queries are added later when we check user is
+        // authenticated.
       case PAGE_WITHOUT_PRELOADING:
         break;
     }
 
     try {
       AccountApi accountApi = gerritApi.accounts().self();
-      initialData.put("\"/accounts/self/detail\"", serializeObject(GSON, accountApi.get()));
       initialData.put(
-          "\"/accounts/self/preferences\"", serializeObject(GSON, accountApi.getPreferences()));
+          addCanonicalUrl("/accounts/self/detail", canonicalURL),
+          serializeObject(GSON, accountApi.get()));
       initialData.put(
-          "\"/accounts/self/preferences.diff\"",
+          addCanonicalUrl("/accounts/self/preferences", canonicalURL),
+          serializeObject(GSON, accountApi.getPreferences()));
+      initialData.put(
+          addCanonicalUrl("/accounts/self/preferences.diff", canonicalURL),
           serializeObject(GSON, accountApi.getDiffPreferences()));
       initialData.put(
-          "\"/accounts/self/preferences.edit\"",
+          addCanonicalUrl("/accounts/self/preferences.edit", canonicalURL),
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
       if (page == RequestedPage.DASHBOARD) {
@@ -133,6 +179,16 @@
     return data.build();
   }
 
+  private static String addCanonicalUrl(String key, String canonicalURL) throws URISyntaxException {
+    String canonicalPath = computeCanonicalPath(canonicalURL);
+
+    if (canonicalPath != null) {
+      return String.format("\"%s\"", canonicalPath + key);
+    }
+
+    return String.format("\"%s\"", key);
+  }
+
   /** Returns experimentData to be used in {@code index.html}. */
   public static Set<String> experimentData(Map<String, String[]> urlParameterMap) {
     // Allow enable experiments with url
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index cc11638..43d0172 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -45,7 +45,8 @@
   }
 
   public static final String CHANGE_CANONICAL_PATH = "/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
-  public static final String BASE_PATCH_NUM_PATH_PART = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
+  public static final String BASE_PATCH_NUM_PATH_PART =
+      "(/(?<basePatchNum>-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
   public static final Pattern CHANGE_URL_PATTERN =
       Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "?" + "/?$");
   public static final Pattern DIFF_URL_PATTERN =
@@ -90,7 +91,22 @@
           ListChangesOption.SUBMIT_REQUIREMENTS,
           ListChangesOption.STAR);
 
-  public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
+  public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS_WITHOUT_PARENTS =
+      ImmutableSet.of(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.DETAILED_LABELS,
+          ListChangesOption.DOWNLOAD_COMMANDS,
+          ListChangesOption.MESSAGES,
+          ListChangesOption.REVIEWER_UPDATES,
+          ListChangesOption.SUBMITTABLE,
+          ListChangesOption.WEB_LINKS,
+          ListChangesOption.SKIP_DIFFSTAT,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
+
+  public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS_WITH_PARENTS =
       ImmutableSet.of(
           ListChangesOption.ALL_COMMITS,
           ListChangesOption.ALL_REVISIONS,
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 514712d..42e10d8 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -63,7 +63,17 @@
 
 public class StaticModule extends ServletModule {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  public static final String CHANGE_NUMBER_URI_REGEX = "^(?:/c)?/([1-9][0-9]*)/?.*";
+  // This constant is copied and NOT reused from UrlModule because of the need for
+  // StaticModule and UrlModule to be used in isolation. The requirement comes
+  // from the way Google includes these two classes in their setup.
+  private static final String CHANGE_NUMBER_REGEX = "(?:/c)?/([1-9][0-9]*)";
+  // Regex matching the direct links to comments using only the change number
+  // 1234/comment/abc_def
+  public static final String CHANGE_NUMBER_URI_REGEX =
+      "^"
+          + CHANGE_NUMBER_REGEX
+          + "(/[1-9][0-9]*(\\.\\.[1-9][0-9]*)?(/[^+]*)?)?(/comment/[^+]+)?/?$";
+
   private static final Pattern CHANGE_NUMBER_URI_PATTERN = Pattern.compile(CHANGE_NUMBER_URI_REGEX);
 
   /**
@@ -225,7 +235,7 @@
         @GerritServerConfig Config cfg,
         GerritApi gerritApi,
         ExperimentFeatures experimentFeatures) {
-      String cdnPath = options.devCdn().orElse(cfg.getString("gerrit", null, "cdnPath"));
+      String cdnPath = options.devCdn().orElseGet(() -> cfg.getString("gerrit", null, "cdnPath"));
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
       return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures);
     }
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 6951398..ce1ac4e 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
@@ -40,7 +39,6 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
@@ -65,7 +63,6 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.NeedsParams;
@@ -92,6 +89,7 @@
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.AclInfoController;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CancellationMetrics;
 import com.google.gerrit.server.CurrentUser;
@@ -110,12 +108,10 @@
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
-import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -149,7 +145,6 @@
 import com.google.inject.util.Providers;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
-import java.io.ByteArrayOutputStream;
 import java.io.EOFException;
 import java.io.FilterOutputStream;
 import java.io.IOException;
@@ -214,6 +209,7 @@
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
   private static final String PLAIN_TEXT = "text/plain";
   private static final Pattern TYPE_SPLIT_PATTERN = Pattern.compile("[ ,;][ ,;]*");
+  private static final long ONE_KB = 1024;
 
   /**
    * Garbage prefix inserted before JSON output to prevent XSSI.
@@ -248,6 +244,7 @@
     final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
     final DeadlineChecker.Factory deadlineCheckerFactory;
     final CancellationMetrics cancellationMetrics;
+    final AclInfoController aclInfoController;
 
     @Inject
     Globals(
@@ -267,7 +264,8 @@
         Injector injector,
         DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
         DeadlineChecker.Factory deadlineCheckerFactory,
-        CancellationMetrics cancellationMetrics) {
+        CancellationMetrics cancellationMetrics,
+        AclInfoController aclInfoController) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -286,6 +284,7 @@
       this.dynamicBeans = dynamicBeans;
       this.deadlineCheckerFactory = deadlineCheckerFactory;
       this.cancellationMetrics = cancellationMetrics;
+      this.aclInfoController = aclInfoController;
     }
   }
 
@@ -333,6 +332,8 @@
         RequestInfo requestInfo = createRequestInfo(traceContext, req, requestUri, path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
+        globals.aclInfoController.enableAclLoggingIfUserCanViewAccess(traceContext);
+
         // It's important that the PerformanceLogContext is closed before the response is sent to
         // the client. Only this way it is ensured that the invocation of the PerformanceLogger
         // plugins happens before the client sees the response. This is needed for being able to
@@ -497,7 +498,7 @@
             checkRequiresCapability(viewData);
           }
 
-          if (notModified(req, traceContext, viewData, rsrc)) {
+          if (notModified(req, rsrc)) {
             logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
             res.sendError(SC_NOT_MODIFIED);
             return;
@@ -521,7 +522,6 @@
                       (RestReadView<RestResource>) viewData.view,
                       rsrc);
             } else if (viewData.view instanceof RestModifyView<?, ?>) {
-              @SuppressWarnings("unchecked")
               RestModifyView<RestResource, Object> m =
                   (RestModifyView<RestResource, Object>) viewData.view;
 
@@ -537,7 +537,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionCreateView<RestResource, RestResource, Object> m =
                   (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
 
@@ -552,7 +551,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
                   (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
                       viewData.view;
@@ -568,7 +566,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionModifyView<RestResource, RestResource, Object> m =
                   (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
@@ -607,7 +604,7 @@
 
             statusCode = response.statusCode();
             response.headers().forEach((k, v) -> res.setHeader(k, v));
-            configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+            configureCaching(req, res, rsrc, response.caching());
             res.setStatus(statusCode);
             logger.atFinest().log("REST call succeeded: %d", statusCode);
           }
@@ -634,9 +631,16 @@
                 req, res, statusCode = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
       } catch (AuthException e) {
         cause = Optional.of(e);
+
+        StringBuilder messageBuilder = new StringBuilder(messageOr(e, "Forbidden"));
+        globals
+            .aclInfoController
+            .getAclInfoMessage()
+            .ifPresent(aclInfo -> messageBuilder.append("\n\n").append(aclInfo));
+
         responseBytes =
             replyError(
-                req, res, statusCode = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
+                req, res, statusCode = SC_FORBIDDEN, messageBuilder.toString(), e.caching(), e);
       } catch (AmbiguousViewException e) {
         cause = Optional.of(e);
         responseBytes =
@@ -800,47 +804,6 @@
     globals.webSession.get().resetRefUpdatedEvents();
   }
 
-  private String getEtagWithRetry(
-      HttpServletRequest req,
-      TraceContext traceContext,
-      ViewData viewData,
-      ETagView<RestResource> view,
-      RestResource rsrc) {
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "RestApiServlet#getEtagWithRetry:view",
-            Metadata.builder().restViewName(getViewName(viewData)).build())) {
-      return invokeRestEndpointWithRetry(
-          req,
-          traceContext,
-          getViewName(viewData) + "#etag",
-          ActionType.REST_READ_REQUEST,
-          () -> view.getETag(rsrc));
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new IllegalStateException("Failed to get ETag for view", e);
-    }
-  }
-
-  @Nullable
-  private String getEtagWithRetry(
-      HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "RestApiServlet#getEtagWithRetry:resource",
-            Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
-      return invokeRestEndpointWithRetry(
-          req,
-          traceContext,
-          rsrc.getClass().getSimpleName() + "#etag",
-          ActionType.REST_READ_REQUEST,
-          () -> rsrc.getETag());
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new IllegalStateException("Failed to get ETag for resource", e);
-    }
-  }
-
   private RestResource parseResourceWithRetry(
       HttpServletRequest req,
       TraceContext traceContext,
@@ -1012,30 +975,11 @@
     return defaultMessage;
   }
 
-  private boolean notModified(
-      HttpServletRequest req, TraceContext traceContext, ViewData viewData, RestResource rsrc) {
+  private boolean notModified(HttpServletRequest req, RestResource rsrc) {
     if (!isRead(req)) {
       return false;
     }
 
-    RestView<RestResource> view = viewData.view;
-    if (view instanceof ETagView) {
-      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (have != null) {
-        String eTag =
-            getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
-        return have.equals(eTag);
-      }
-    }
-
-    if (rsrc instanceof RestResource.HasETag) {
-      String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (!Strings.isNullOrEmpty(have)) {
-        String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
-        return have.equals(eTag);
-      }
-    }
-
     if (rsrc instanceof RestResource.HasLastModified) {
       Timestamp m = ((RestResource.HasLastModified) rsrc).getLastModified();
       long d = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
@@ -1047,12 +991,7 @@
   }
 
   private <R extends RestResource> void configureCaching(
-      HttpServletRequest req,
-      HttpServletResponse res,
-      TraceContext traceContext,
-      R rsrc,
-      ViewData viewData,
-      CacheControl cacheControl) {
+      HttpServletRequest req, HttpServletResponse res, R rsrc, CacheControl cacheControl) {
     setCacheHeaders(req, res, cacheControl);
     if (isRead(req)) {
       switch (cacheControl.getType()) {
@@ -1060,10 +999,10 @@
         default:
           break;
         case PRIVATE:
-          addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+          addResourceStateHeaders(res, rsrc);
           break;
         case PUBLIC:
-          addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+          addResourceStateHeaders(res, rsrc);
           break;
       }
     }
@@ -1095,23 +1034,7 @@
     }
   }
 
-  private void addResourceStateHeaders(
-      HttpServletRequest req,
-      HttpServletResponse res,
-      TraceContext traceContext,
-      ViewData viewData,
-      RestResource rsrc) {
-    RestView<RestResource> view = viewData.view;
-    if (view instanceof ETagView) {
-      String eTag =
-          getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
-      res.setHeader(HttpHeaders.ETAG, eTag);
-    } else if (rsrc instanceof RestResource.HasETag) {
-      String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
-      if (!Strings.isNullOrEmpty(eTag)) {
-        res.setHeader(HttpHeaders.ETAG, eTag);
-      }
-    }
+  private void addResourceStateHeaders(HttpServletResponse res, RestResource rsrc) {
     if (rsrc instanceof RestResource.HasLastModified) {
       res.setDateHeader(
           HttpHeaders.LAST_MODIFIED,
@@ -1331,22 +1254,12 @@
     w.write('\n');
     w.flush();
 
-    if (allowTracing) {
-      logger.atFinest().log(
-          "JSON response body:\n%s",
-          lazy(
-              () -> {
-                try {
-                  ByteArrayOutputStream debugOut = new ByteArrayOutputStream();
-                  buf.writeTo(debugOut, null);
-                  return debugOut.toString(UTF_8.name());
-                } catch (IOException e) {
-                  return "<JSON formatting failed>";
-                }
-              }));
+    BinaryResult binaryResult = asBinaryResult(buf);
+    if (allowTracing && binaryResult.getContentLength() <= ONE_KB) {
+      logger.atFinest().log("JSON response body:\n%s", binaryResult.asString());
     }
     return replyBinaryResult(
-        req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
+        req, res, binaryResult.setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
   }
 
   private static Gson newGson(ListMultimap<String, String> config) {
@@ -1734,8 +1647,6 @@
             clientProvidedDeadline ->
                 logger.atFine().log("%s = %s", X_GERRIT_DEADLINE, clientProvidedDeadline));
     logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
-    logger.atFinest().log(
-        "Groups: %s", lazy(() -> globals.currentUser.get().getEffectiveGroups().getKnownGroups()));
   }
 
   private boolean isDelete(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index ec530c1..fdf806c 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -75,6 +75,9 @@
   /** Delete all documents from the index. */
   void deleteAll();
 
+  /** Return the number of documents in this index */
+  int numDocs();
+
   /**
    * Convert the given operator predicate into a source searching the index and returning only the
    * documents matching that predicate.
diff --git a/java/com/google/gerrit/index/IndexDefinition.java b/java/com/google/gerrit/index/IndexDefinition.java
index f283bf1..cbcb34e 100644
--- a/java/com/google/gerrit/index/IndexDefinition.java
+++ b/java/com/google/gerrit/index/IndexDefinition.java
@@ -67,7 +67,11 @@
   }
 
   @Nullable
-  public final SiteIndexer<K, V, I> getSiteIndexer() {
+  public SiteIndexer<K, V, I> getSiteIndexer() {
+    return siteIndexer;
+  }
+
+  public SiteIndexer<K, V, I> getSiteIndexer(boolean reuseExistingDocuments) {
     return siteIndexer;
   }
 }
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index bee8fa1..632e469 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -204,4 +204,8 @@
         allowIncompleteResults(),
         filter.apply(this));
   }
+
+  public int getLimitBasedOnPaginationType() {
+    return PaginationType.NONE == config().paginationType() ? limit() : pageSize();
+  }
 }
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index 32b4b21..bfb4407 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -76,6 +76,16 @@
   /** Indexes all entities for the provided index. */
   public abstract Result indexAll(I index);
 
+  /**
+   * Indexes all entities for the provided index.
+   *
+   * <p>NOTE: This method does not implement the 'notifyListeners' logic which is effectively
+   * ignored and all listeners are always notified.
+   */
+  public Result indexAll(I index, @SuppressWarnings("unused") boolean notifyListeners) {
+    return indexAll(index);
+  }
+
   protected final void addErrorListener(
       ListenableFuture<?> future, String desc, ProgressMonitor progress, AtomicBoolean ok) {
     future.addListener(
diff --git a/java/com/google/gerrit/index/query/FilteredSource.java b/java/com/google/gerrit/index/query/FilteredSource.java
index 9746850..95e6435 100644
--- a/java/com/google/gerrit/index/query/FilteredSource.java
+++ b/java/com/google/gerrit/index/query/FilteredSource.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -51,11 +52,36 @@
     return new LazyResultSet<>(
         () -> {
           List<T> r = new ArrayList<>();
+          T last = null;
+          int pageResultSize = 0;
           for (T data : buffer(resultSet)) {
             if (!isMatchable() || match(data)) {
               r.add(data);
             }
+            last = data;
+            pageResultSize++;
           }
+
+          if (last != null && source instanceof Paginated) {
+            // Restart source and continue if we have not filled the
+            // full limit the caller wants.
+            Paginated<T> p = (Paginated<T>) source;
+            QueryOptions opts = p.getOptions();
+            final int limit = opts.limit();
+            int nextStart = pageResultSize;
+            while (pageResultSize == limit && r.size() < limit) {
+              ResultSet<T> next = p.restart(nextStart);
+              pageResultSize = 0;
+              for (T data : buffer(next)) {
+                if (match(data)) {
+                  r.add(data);
+                }
+                pageResultSize++;
+              }
+              nextStart += pageResultSize;
+            }
+          }
+
           if (start >= r.size()) {
             return ImmutableList.of();
           } else if (start > 0) {
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index ee25ef9..cb98c06 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -87,6 +87,12 @@
   }
 
   @Override
+  public ResultSet<T> restart(int start) {
+    opts = opts.withStart(start);
+    return search();
+  }
+
+  @Override
   public ResultSet<T> restart(int start, int pageSize) {
     opts = opts.withStart(start).withPageSize(pageSize);
     return search();
diff --git a/java/com/google/gerrit/index/query/Paginated.java b/java/com/google/gerrit/index/query/Paginated.java
index 5521990..1065019 100644
--- a/java/com/google/gerrit/index/query/Paginated.java
+++ b/java/com/google/gerrit/index/query/Paginated.java
@@ -19,6 +19,8 @@
 public interface Paginated<T> {
   QueryOptions getOptions();
 
+  ResultSet<T> restart(int start);
+
   ResultSet<T> restart(int start, int pageSize);
 
   ResultSet<T> restart(Object searchAfter, int pageSize);
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index be789ee..19251ca 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -54,7 +54,6 @@
           if (last != null && source instanceof Paginated) {
             // Restart source and continue if we have not filled the
             // full limit the caller wants.
-            //
             @SuppressWarnings("unchecked")
             Paginated<T> p = (Paginated<T>) source;
             QueryOptions opts = p.getOptions();
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index b8eb8bb..6d98f53 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.flogger.LazyArgs.lazy;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Throwables;
@@ -40,7 +39,6 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.logging.Metadata;
 import java.util.ArrayList;
 import java.util.List;
@@ -83,7 +81,6 @@
   private final IndexRewriter<T> rewriter;
   private final String limitField;
   private final IntSupplier userQueryLimit;
-  private final CallerFinder callerFinder;
 
   // This class is not generally thread-safe, but programmer error may result in it being shared
   // across threads. At least ensure the bit for checking if it's been used is threadsafe.
@@ -113,13 +110,6 @@
     this.limitField = limitField;
     this.userQueryLimit = userQueryLimit;
     this.used = new AtomicBoolean(false);
-    this.callerFinder =
-        CallerFinder.builder()
-            .addTarget(InternalQuery.class)
-            .addTarget(QueryProcessor.class)
-            .matchSubClasses(true)
-            .skip(1)
-            .build();
   }
 
   @CanIgnoreReturnValue
@@ -242,9 +232,7 @@
       return disabledResults(queryStrings, queries);
     }
 
-    logger.atFine().log(
-        "Executing %d %s index queries for %s",
-        cnt, schemaDef.getName(), callerFinder.findCallerLazy());
+    logger.atFine().log("Executing %d %s index queries", cnt, schemaDef.getName());
     List<QueryResult<T>> out;
     try {
       // Parse and rewrite all queries.
@@ -335,7 +323,7 @@
         int limit = limits.get(i);
         logger.atFine().log(
             "Matches[%d]:\n%s",
-            i, lazy(() -> matchesList.stream().map(this::formatForLogging).collect(toList())));
+            i, matchesList.stream().map(this::formatForLogging).collect(toList()));
         // TODO(brohlfs): Remove this extra logging by end of Q3 2023.
         if (limit > 500 && userProvidedLimit <= 0 && matchCount > 100 && enforceVisibility) {
           logger.atWarning().log(
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 65c02d9..b06143e 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -119,6 +119,13 @@
     }
   }
 
+  @Override
+  public int numDocs() {
+    synchronized (indexedDocuments) {
+      return indexedDocuments.size();
+    }
+  }
+
   public int getQueryCount() {
     return queryCount;
   }
@@ -150,10 +157,14 @@
                     .findFirst()
                     .orElse(-1)
                 + 1;
-        int toIndex = Math.min(fromIndex + opts.pageSize(), valueList.size());
+        int toIndex = Math.min(fromIndex + opts.getLimitBasedOnPaginationType(), valueList.size());
         results = valueList.subList(fromIndex, toIndex);
       } else {
-        results = valueStream.skip(opts.start()).limit(opts.pageSize()).collect(toImmutableList());
+        results =
+            valueStream
+                .skip(opts.start())
+                .limit(opts.getLimitBasedOnPaginationType())
+                .collect(toImmutableList());
       }
       queryCount++;
       resultsSizes.add(results.size());
diff --git a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
index 5044e38..40d51fd 100644
--- a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
+++ b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
@@ -40,7 +40,12 @@
       SitePaths sitePaths,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
+    super(
+        sitePaths,
+        listeners,
+        defs,
+        VersionManager.getOnlineUpgrade(cfg),
+        cfg.getBoolean("index", "reuseExistingDocuments", false));
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index e00c394..85ffd93 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -326,6 +326,21 @@
     }
   }
 
+  @Override
+  public int numDocs() {
+    try {
+      IndexSearcher searcher = acquire();
+      try {
+        return searcher.getIndexReader().numDocs();
+      } finally {
+        release(searcher);
+      }
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(e.getMessage());
+      throw new StorageException(e);
+    }
+  }
+
   public IndexWriter getWriter() {
     return writer;
   }
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 9fcf5ae..e5f7787 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGENUM_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
@@ -113,7 +114,7 @@
   private static final String CHANGE_FIELD = ChangeField.CHANGE_SPEC.getName();
 
   static Term idTerm(ChangeData cd) {
-    return idTerm(cd.getVirtualId());
+    return idTerm(cd.virtualId());
   }
 
   static Term idTerm(Change.Id id) {
@@ -264,6 +265,11 @@
   }
 
   @Override
+  public int numDocs() {
+    return openIndex.numDocs() + closedIndex.numDocs();
+  }
+
+  @Override
   public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
       throws QueryParseException {
     Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
@@ -405,11 +411,11 @@
       IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
       Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex = new HashMap<>();
       try {
-        int realPageSize = opts.start() + opts.pageSize();
-        if (Integer.MAX_VALUE - opts.pageSize() < opts.start()) {
-          realPageSize = Integer.MAX_VALUE;
+        int pageLimit = AbstractLuceneIndex.getLimitBasedOnPaginationType(opts, opts.pageSize());
+        int queryLimit = opts.start() + pageLimit;
+        if (Integer.MAX_VALUE - pageLimit < opts.start()) {
+          queryLimit = Integer.MAX_VALUE;
         }
-        int queryLimit = AbstractLuceneIndex.getLimitBasedOnPaginationType(opts, realPageSize);
         List<TopFieldDocs> hits = new ArrayList<>();
         int searchAfterHitsCount = 0;
         for (int i = 0; i < indexes.size(); i++) {
@@ -417,7 +423,7 @@
           searchers[i] = subIndex.acquire();
           if (isSearchAfterPagination) {
             ScoreDoc searchAfter = getSearchAfter(subIndex);
-            int maxRemainingHits = realPageSize - searchAfterHitsCount;
+            int maxRemainingHits = queryLimit - searchAfterHitsCount;
             if (maxRemainingHits > 0) {
               TopFieldDocs subIndexHits =
                   searchers[i].searchAfter(
@@ -528,7 +534,11 @@
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, NUMERIC_ID_STR_SPEC.getName()));
+          String fieldName =
+              doc.getField(CHANGENUM_SPEC.getName()) != null
+                  ? CHANGENUM_SPEC.getName()
+                  : NUMERIC_ID_STR_SPEC.getName();
+          result.add(toChangeData(fields(doc, fields), fields, fieldName));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -571,7 +581,13 @@
     IndexableField cb = Iterables.getFirst(doc.get(CHANGE_FIELD), null);
     if (cb != null) {
       BytesRef proto = cb.binaryValue();
-      cd = changeDataFactory.create(parseProtoFrom(proto, ChangeProtoConverter.INSTANCE));
+      // pass the id field value (which is the change virtual id for the imported changes) when
+      // available
+      IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
+      cd =
+          changeDataFactory.create(
+              parseProtoFrom(proto, ChangeProtoConverter.INSTANCE),
+              f != null ? Change.id(Integer.valueOf(f.stringValue())) : null);
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
 
diff --git a/java/com/google/gerrit/lucene/LuceneVersionManager.java b/java/com/google/gerrit/lucene/LuceneVersionManager.java
index f3ba73d..265d3e0 100644
--- a/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -49,7 +49,12 @@
       SitePaths sitePaths,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
+    super(
+        sitePaths,
+        listeners,
+        defs,
+        VersionManager.getOnlineUpgrade(cfg),
+        cfg.getBoolean("index", "reuseExistingDocuments", false));
   }
 
   @Override
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index d59595a..ac390f5 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -49,6 +49,8 @@
     }
   }
 
+  private boolean suppressLogging;
+
   protected final String name;
 
   public Timer0(String name) {
@@ -62,6 +64,9 @@
    */
   public Context start() {
     RequestStateContext.abortIfCancelled();
+    if (!suppressLogging) {
+      logger.atFine().log("Starting timer %s", name);
+    }
     return new Context(this);
   }
 
@@ -72,16 +77,24 @@
    * @param unit time unit of the value
    */
   public final void record(long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
-    LoggingContext.getInstance()
-        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
-    logger.atFinest().log("%s took %dms", name, durationMs);
+    if (!suppressLogging) {
+      LoggingContext.getInstance()
+          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationNanos));
+      logger.atFinest().log("%s took %.2f ms", name, durationNanos / 1000000.0);
+    }
 
     doRecord(value, unit);
     RequestStateContext.abortIfCancelled();
   }
 
+  /** Suppress logging (debug log and performance log) when values are recorded. */
+  public final Timer0 suppressLogging() {
+    this.suppressLogging = true;
+    return this;
+  }
+
   /**
    * Record a value in the distribution.
    *
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index eefd462..04d6247 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -72,6 +72,9 @@
    */
   public Context<F1> start(F1 fieldValue) {
     RequestStateContext.abortIfCancelled();
+    if (!suppressLogging) {
+      logger.atFine().log("Starting timer %s (%s = %s)", name, field.name(), fieldValue);
+    }
     return new Context<>(this, fieldValue);
   }
 
@@ -83,7 +86,7 @@
    * @param unit time unit of the value
    */
   public final void record(F1 fieldValue, long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
     Metadata.Builder metadataBuilder = Metadata.builder();
     field.metadataMapper().accept(metadataBuilder, fieldValue);
@@ -91,8 +94,10 @@
 
     if (!suppressLogging) {
       LoggingContext.getInstance()
-          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
-      logger.atFinest().log("%s (%s = %s) took %dms", name, field.name(), fieldValue, durationMs);
+          .addPerformanceLogRecord(
+              () -> PerformanceLogRecord.create(name, durationNanos, metadata));
+      logger.atFinest().log(
+          "%s (%s = %s) took %.2f ms", name, field.name(), fieldValue, durationNanos / 1000000.0);
     }
 
     doRecord(fieldValue, value, unit);
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index 09878ad..f526f05 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -78,6 +78,11 @@
    */
   public Context<F1, F2> start(F1 fieldValue1, F2 fieldValue2) {
     RequestStateContext.abortIfCancelled();
+    if (!suppressLogging) {
+      logger.atFine().log(
+          "Starting timer %s (%s = %s, %s = %s)",
+          name, field1.name(), fieldValue1, field2.name(), fieldValue2);
+    }
     return new Context<>(this, fieldValue1, fieldValue2);
   }
 
@@ -90,7 +95,7 @@
    * @param unit time unit of the value
    */
   public final void record(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
     Metadata.Builder metadataBuilder = Metadata.builder();
     field1.metadataMapper().accept(metadataBuilder, fieldValue1);
@@ -99,10 +104,11 @@
 
     if (!suppressLogging) {
       LoggingContext.getInstance()
-          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+          .addPerformanceLogRecord(
+              () -> PerformanceLogRecord.create(name, durationNanos, metadata));
       logger.atFinest().log(
-          "%s (%s = %s, %s = %s) took %dms",
-          name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationMs);
+          "%s (%s = %s, %s = %s) took %.2f ms",
+          name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationNanos / 1000000.0);
     }
 
     doRecord(fieldValue1, fieldValue2, value, unit);
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index 5d5c424..1735dc8 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -84,6 +84,11 @@
    */
   public Context<F1, F2, F3> start(F1 fieldValue1, F2 fieldValue2, F3 fieldValue3) {
     RequestStateContext.abortIfCancelled();
+    if (!suppressLogging) {
+      logger.atFine().log(
+          "Starting timer %s (%s = %s, %s = %s, %s = %s)",
+          name, field1.name(), fieldValue1, field2.name(), fieldValue2, field3.name(), fieldValue3);
+    }
     return new Context<>(this, fieldValue1, fieldValue2, fieldValue3);
   }
 
@@ -98,7 +103,7 @@
    */
   public final void record(
       F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit) {
-    long durationMs = unit.toMillis(value);
+    long durationNanos = unit.toNanos(value);
 
     Metadata.Builder metadataBuilder = Metadata.builder();
     field1.metadataMapper().accept(metadataBuilder, fieldValue1);
@@ -108,9 +113,10 @@
 
     if (!suppressLogging) {
       LoggingContext.getInstance()
-          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+          .addPerformanceLogRecord(
+              () -> PerformanceLogRecord.create(name, durationNanos, metadata));
       logger.atFinest().log(
-          "%s (%s = %s, %s = %s, %s = %s) took %dms",
+          "%s (%s = %s, %s = %s, %s = %s) took %.2f ms",
           name,
           field1.name(),
           fieldValue1,
@@ -118,7 +124,7 @@
           fieldValue2,
           field3.name(),
           fieldValue3,
-          durationMs);
+          durationNanos / 1000000.0);
     }
 
     doRecord(fieldValue1, fieldValue2, fieldValue3, value, unit);
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index d213a60..4a47e5ad 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -52,7 +52,7 @@
 import com.google.gerrit.pgm.http.jetty.JettyModule;
 import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter.ProjectQoSFilterModule;
 import com.google.gerrit.pgm.util.ErrorLogFile;
-import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.pgm.util.LogFileManager.LogFileManagerModule;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
 import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
@@ -92,15 +93,16 @@
 import com.google.gerrit.server.git.ChangesByProjectCache;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
-import com.google.gerrit.server.group.PeriodicGroupIndexer.PeriodicGroupIndexerModule;
 import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.options.AutoFlush;
+import com.google.gerrit.server.index.scheduler.PeriodicIndexScheduler;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
+import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
 import com.google.gerrit.server.mime.MimeUtil2Module;
 import com.google.gerrit.server.notedb.NoteDbDraftCommentsModule;
@@ -110,7 +112,7 @@
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
-import com.google.gerrit.server.project.DefaultProjectNameLockManager.DefaultProjectNameLockManagerModule;
+import com.google.gerrit.server.project.DefaultLockManager.DefaultLockManagerModule;
 import com.google.gerrit.server.restapi.RestApiModule;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore.JdbcAccountPatchReviewStoreModule;
 import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
@@ -383,11 +385,11 @@
   @VisibleForTesting
   public void start() throws IOException {
     if (dbInjector == null) {
-      dbInjector = createDbInjector(true /* enableMetrics */);
+      dbInjector =
+          createDbInjector(true /* enableMetrics */, new GerritOptions(headless, replica, devCdn));
     }
     cfgInjector = createCfgInjector();
     config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    config.setBoolean("container", null, "replica", replica);
     indexType = IndexModule.getIndexType(cfgInjector);
     sysInjector = createSysInjector();
     sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
@@ -450,7 +452,7 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressorModule());
+    modules.add(new LogFileManagerModule());
 
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
@@ -475,7 +477,10 @@
 
     modules.add(new AccountNoteDbWriteStorageModule());
     modules.add(new AccountNoteDbReadStorageModule());
+    modules.add(new ExternalIdCacheImpl.ExternalIdCacheModule());
+    modules.add(new ExternalIdCacheImpl.ExternalIdCacheBindingModule());
     modules.add(new RepoSequenceModule());
+    modules.add(new FromAddressGeneratorProvider.UserAddressGenModule());
     modules.add(new NoteDbDraftCommentsModule());
     modules.add(new NoteDbStarredChangesModule());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
@@ -543,7 +548,6 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(headless, replica, devCdn));
             if (inMemoryTest) {
               bind(String.class)
                   .annotatedWith(SecureStoreClassName.class)
@@ -553,15 +557,14 @@
           }
         });
     modules.add(new GarbageCollectionModule());
-    if (replica) {
-      modules.add(new PeriodicGroupIndexerModule());
-    } else {
+    modules.add(new PeriodicIndexScheduler.Module());
+    if (!replica) {
       modules.add(new AccountDeactivatorModule());
       modules.add(new AttentionSetOwnerAdderModule());
       modules.add(new ChangeCleanupRunnerModule());
     }
     modules.add(new LocalMergeSuperSetComputationModule());
-    modules.add(new DefaultProjectNameLockManagerModule());
+    modules.add(new DefaultLockManagerModule());
 
     List<Module> libModules =
         LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE_TYPE);
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index d3e9988..fc5a2c7 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.pgm.init.InitPlugins;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.util.ErrorLogFile;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.index.GerritIndexStatus;
@@ -159,6 +160,7 @@
             bind(String.class)
                 .annotatedWith(SecureStoreClassName.class)
                 .toProvider(Providers.of(getConfiguredSecureStoreClass()));
+            bind(GerritOptions.class).toInstance(GerritOptions.DEFAULT);
           }
         });
     modules.add(new GerritServerConfigModule());
diff --git a/java/com/google/gerrit/pgm/Passwd.java b/java/com/google/gerrit/pgm/Passwd.java
index 10ed07d..dbb5318 100644
--- a/java/com/google/gerrit/pgm/Passwd.java
+++ b/java/com/google/gerrit/pgm/Passwd.java
@@ -36,33 +36,40 @@
 
 public class Passwd extends SiteProgram {
   private String section;
+  private String subsection;
   private String key;
 
   @Argument(
-      metaVar = "SECTION.KEY",
+      metaVar = "SECTION.[SUBSECTION.]KEY",
       index = 0,
       required = true,
-      usage = "Section and key separated by a dot of the password to set")
-  private String sectionAndKey;
+      usage =
+          "Section, subsection and key separated by a dot of the password to set. Subsection is optional")
+  private String sectionSubsectionAndKey;
 
   @Argument(metaVar = "PASSWORD", index = 1, required = false, usage = "Password to set")
   private String password;
 
   private void init() {
-    List<String> varParts = Splitter.on('.').splitToList(sectionAndKey);
-    if (varParts.size() != 2) {
+    List<String> varParts = Splitter.on('.').splitToList(sectionSubsectionAndKey);
+    if (varParts.size() != 2 && varParts.size() != 3) {
       throw new IllegalArgumentException(
-          "Invalid name '" + sectionAndKey + "': expected section.key format");
+          "Invalid name '"
+              + sectionSubsectionAndKey
+              + "': expected section.[subsection.]key format");
     }
     section = varParts.get(0);
-    key = varParts.get(1);
+    if (varParts.size() == 3) {
+      subsection = varParts.get(1);
+    }
+    key = varParts.get(varParts.size() - 1);
   }
 
   @Override
   public int run() throws Exception {
     init();
     SetPasswd setPasswd = getSysInjector().getInstance(SetPasswd.class);
-    setPasswd.run(section, key, password);
+    setPasswd.run(section, subsection, key, password);
     return 0;
   }
 
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 2ed2b76..7424407 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
 import com.google.gerrit.server.cache.CacheDisplay;
@@ -98,14 +99,22 @@
   @Option(name = "--build-bloom-filter", usage = "Build bloom filter for H2 disk caches.")
   private boolean buildBloomFilter;
 
+  private Boolean reuseExistingDocumentsOption;
+
   private Injector dbInjector;
   private Injector sysInjector;
   private Injector cfgInjector;
   private Config globalConfig;
+  private boolean reuseExistingDocuments;
 
   @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
   @Inject private DynamicMap<Cache<?, ?>> cacheMap;
 
+  @Option(name = "--reuse", usage = "Reindex only when existing index entry is stale")
+  public void setReuseExistingDocuments(boolean value) {
+    reuseExistingDocumentsOption = value;
+  }
+
   @Override
   public int run() throws Exception {
     mustHaveValidSite();
@@ -113,6 +122,10 @@
     cfgInjector = dbInjector.createChildInjector();
     globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     overrideConfig();
+    reuseExistingDocuments =
+        reuseExistingDocumentsOption != null
+            ? reuseExistingDocumentsOption
+            : globalConfig.getBoolean("index", null, "reuseExistingDocuments", false);
     LifecycleManager dbManager = new LifecycleManager();
     dbManager.add(dbInjector);
     dbManager.start();
@@ -214,7 +227,8 @@
             super.configure();
             OptionalBinder.newOptionalBinder(binder(), IsFirstInsertForEntry.class)
                 .setBinding()
-                .toInstance(IsFirstInsertForEntry.YES);
+                .toInstance(
+                    reuseExistingDocuments ? IsFirstInsertForEntry.NO : IsFirstInsertForEntry.YES);
             OptionalBinder.newOptionalBinder(binder(), BuildBloomFilter.class)
                 .setBinding()
                 .toInstance(buildBloomFilter ? BuildBloomFilter.TRUE : BuildBloomFilter.FALSE);
@@ -230,6 +244,8 @@
         });
     modules.add(new AccountNoteDbWriteStorageModule());
     modules.add(new AccountNoteDbReadStorageModule());
+    modules.add(new ExternalIdCacheImpl.ExternalIdCacheModule());
+    modules.add(new ExternalIdCacheImpl.ExternalIdCacheBindingModule());
     modules.add(new RepoSequenceModule());
     modules.add(new NoteDbDraftCommentsModule());
     modules.add(new NoteDbStarredChangesModule());
@@ -258,9 +274,12 @@
     requireNonNull(
         index, () -> String.format("no active search index configured for %s", def.getName()));
     index.markReady(false);
-    index.deleteAll();
 
-    SiteIndexer<K, V, I> siteIndexer = def.getSiteIndexer();
+    if (!reuseExistingDocuments) {
+      index.deleteAll();
+    }
+
+    SiteIndexer<K, V, I> siteIndexer = def.getSiteIndexer(reuseExistingDocuments);
     siteIndexer.setProgressOut(System.err);
     siteIndexer.setVerboseOut(verbose ? System.out : NullOutputStream.INSTANCE);
     SiteIndexer.Result result = siteIndexer.indexAll(index);
diff --git a/java/com/google/gerrit/pgm/SetPasswd.java b/java/com/google/gerrit/pgm/SetPasswd.java
index c3f6a7b..edf3870 100644
--- a/java/com/google/gerrit/pgm/SetPasswd.java
+++ b/java/com/google/gerrit/pgm/SetPasswd.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.inject.Inject;
@@ -29,8 +30,9 @@
     this.sections = sections;
   }
 
-  public void run(String section, String key, String password) throws Exception {
-    Section passwordSection = sections.get(section, null);
+  public void run(String section, @Nullable String subsection, String key, String password)
+      throws Exception {
+    Section passwordSection = sections.get(section, subsection);
 
     if (ui.isBatch()) {
       passwordSection.setSecure(key, password);
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
index e3e485f..5584eba 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
@@ -110,6 +110,7 @@
         throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
       }
       account.setMetaId(id.name());
+      account.setUniqueTag(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 1f56512..e0eb773 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
@@ -275,6 +276,7 @@
             bind(Boolean.class).annotatedWith(LibraryDownload.class).toInstance(skipAllDownloads());
 
             bind(MetricMaker.class).to(DisabledMetricMaker.class);
+            bind(GerritOptions.class).toInstance(GerritOptions.DEFAULT);
           }
         });
 
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index f45f1be..f30efd4 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
-import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -99,6 +98,7 @@
 import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.SubmitRequirementLabelExtensionPredicate;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -181,7 +181,6 @@
     modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
-    modules.add(new ExternalIdCacheModule());
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
@@ -203,6 +202,7 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
+    factory(SubmitRequirementLabelExtensionPredicate.Factory.class);
     factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
diff --git a/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
deleted file mode 100644
index 5e49312..0000000
--- a/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.util;
-
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.temporal.ChronoUnit;
-import java.util.concurrent.Future;
-import java.util.zip.GZIPOutputStream;
-import org.eclipse.jgit.lib.Config;
-
-/** Compresses the old error logs. */
-public class LogFileCompressor implements Runnable {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static class LogFileCompressorModule extends LifecycleModule {
-    @Override
-    protected void configure() {
-      listener().to(Lifecycle.class);
-    }
-  }
-
-  static class Lifecycle implements LifecycleListener {
-    private final WorkQueue queue;
-    private final LogFileCompressor compressor;
-    private final boolean enabled;
-
-    @Inject
-    Lifecycle(WorkQueue queue, LogFileCompressor compressor, @GerritServerConfig Config config) {
-      this.queue = queue;
-      this.compressor = compressor;
-      this.enabled = config.getBoolean("log", "compress", true);
-    }
-
-    @Override
-    public void start() {
-      if (!enabled) {
-        return;
-      }
-      // compress log once and then schedule compression every day at 11:00pm
-      queue.getDefaultQueue().execute(compressor);
-      ZoneId zone = ZoneId.systemDefault();
-      LocalDateTime now = LocalDateTime.now(zone);
-      long milliSecondsUntil11pm =
-          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
-      @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError =
-          queue
-              .getDefaultQueue()
-              .scheduleAtFixedRate(
-                  compressor, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
-    }
-
-    @Override
-    public void stop() {}
-  }
-
-  private final Path logs_dir;
-
-  @Inject
-  LogFileCompressor(SitePaths site) {
-    logs_dir = resolve(site.logs_dir);
-  }
-
-  private static Path resolve(Path p) {
-    try {
-      return p.toRealPath().normalize();
-    } catch (IOException e) {
-      return p.toAbsolutePath().normalize();
-    }
-  }
-
-  @Override
-  public void run() {
-    try {
-      if (!Files.isDirectory(logs_dir)) {
-        return;
-      }
-      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
-        for (Path entry : list) {
-          if (!isLive(entry) && !isCompressed(entry) && isLogFile(entry)) {
-            compress(entry);
-          }
-        }
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Error listing logs to compress in %s", logs_dir);
-      }
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Failed to compress log files: %s", e.getMessage());
-    }
-  }
-
-  private boolean isLive(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith("_log")
-        || name.endsWith(".log")
-        || name.endsWith(".run")
-        || name.endsWith(".pid")
-        || name.endsWith(".json");
-  }
-
-  private boolean isCompressed(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith(".gz") //
-        || name.endsWith(".zip") //
-        || name.endsWith(".bz2");
-  }
-
-  private boolean isLogFile(Path entry) {
-    return Files.isRegularFile(entry);
-  }
-
-  private void compress(Path src) {
-    Path dst = src.resolveSibling(src.getFileName() + ".gz");
-    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
-    try {
-      try (InputStream in = Files.newInputStream(src);
-          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
-        ByteStreams.copy(in, out);
-      }
-      tmp.toFile().setReadOnly();
-      try {
-        Files.move(tmp, dst);
-      } catch (IOException e) {
-        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
-      }
-      Files.delete(src);
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot compress %s", src);
-      try {
-        Files.deleteIfExists(tmp);
-      } catch (IOException e2) {
-        logger.atWarning().withCause(e2).log("Failed to delete temporary log file %s", tmp);
-      }
-    }
-  }
-
-  @Override
-  public String toString() {
-    return "Log File Compressor";
-  }
-}
diff --git a/java/com/google/gerrit/pgm/util/LogFileManager.java b/java/com/google/gerrit/pgm/util/LogFileManager.java
new file mode 100644
index 0000000..902f7d64
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/LogFileManager.java
@@ -0,0 +1,249 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPOutputStream;
+import org.eclipse.jgit.lib.Config;
+
+/** Compresses and eventually deletes the old logs. */
+public class LogFileManager implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final Pattern LOG_FILENAME_PATTERN =
+      Pattern.compile("^.+(?<date>\\d{4}-\\d{2}-\\d{2})(.gz)?");
+  protected final boolean compressionEnabled;
+  private final Duration timeToKeep;
+
+  public static class LogFileManagerModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  static class Lifecycle implements LifecycleListener {
+    private final WorkQueue queue;
+    private final LogFileManager manager;
+
+    @Inject
+    Lifecycle(WorkQueue queue, LogFileManager manager) {
+      this.queue = queue;
+      this.manager = manager;
+    }
+
+    @Override
+    public void start() {
+      if (!manager.compressionEnabled && manager.timeToKeep.isNegative()) {
+        return;
+      }
+      // compress log once and then schedule compression every day at 11:00pm
+      queue.getDefaultQueue().execute(manager);
+      ZoneId zone = ZoneId.systemDefault();
+      LocalDateTime now = LocalDateTime.now(zone);
+      long milliSecondsUntil11pm =
+          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError =
+          queue
+              .getDefaultQueue()
+              .scheduleAtFixedRate(
+                  manager, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
+    }
+
+    @Override
+    public void stop() {}
+  }
+
+  private final Path logs_dir;
+
+  @Inject
+  LogFileManager(SitePaths site, @GerritServerConfig Config config) {
+    this.logs_dir = resolve(site.logs_dir);
+    this.compressionEnabled = config.getBoolean("log", "compress", true);
+    this.timeToKeep = getTimeToKeep(config);
+  }
+
+  private Duration getTimeToKeep(Config config) {
+    try {
+      return Duration.ofDays(
+          ConfigUtil.getTimeUnit(config, "log", null, "timeToKeep", -1, TimeUnit.DAYS));
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).log(
+          "Illegal duration value for log deletion. Disabling log deletion.");
+      return Duration.ofDays(-1L);
+    }
+  }
+
+  private static Path resolve(Path p) {
+    try {
+      return p.toRealPath().normalize();
+    } catch (IOException e) {
+      return p.toAbsolutePath().normalize();
+    }
+  }
+
+  @Override
+  public void run() {
+    logger.atInfo().log("Starting log file maintenance.");
+    try {
+      if (!Files.isDirectory(logs_dir)) {
+        return;
+      }
+      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
+        for (Path entry : list) {
+          if (isLive(entry) || !isLogFile(entry)) {
+            continue;
+          }
+          if (!timeToKeep.isNegative() && isExpired(entry)) {
+            if (delete(entry)) {
+              continue;
+            }
+          }
+          if (compressionEnabled && !isCompressed(entry)) {
+            compress(entry);
+          }
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Error listing logs to compress in %s", logs_dir);
+      }
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Failed to process log files: %s", e.getMessage());
+    }
+    logger.atInfo().log("Log file maintenance has finished.");
+  }
+
+  private boolean isLive(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith("_log")
+        || name.endsWith(".log")
+        || name.endsWith(".run")
+        || name.endsWith(".pid")
+        || name.endsWith(".json");
+  }
+
+  private boolean isCompressed(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith(".gz") //
+        || name.endsWith(".zip") //
+        || name.endsWith(".bz2");
+  }
+
+  private boolean isLogFile(Path entry) {
+    return Files.isRegularFile(entry);
+  }
+
+  @VisibleForTesting
+  boolean isExpired(Path entry) {
+    try {
+      FileTime creationTime = Files.readAttributes(entry, BasicFileAttributes.class).creationTime();
+
+      if (creationTime.toInstant().equals(Instant.EPOCH)) {
+        Optional<Instant> fileDate = getDateFromFilename(entry);
+        if (fileDate.isPresent()) {
+          return fileDate.get().isBefore(Instant.now().minus(timeToKeep));
+        }
+        return false;
+      }
+
+      return creationTime.toInstant().isBefore(Instant.now().minus(timeToKeep));
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Failed to get creation time of log file %s", entry);
+    }
+    return false;
+  }
+
+  @VisibleForTesting
+  Optional<Instant> getDateFromFilename(Path entry) {
+    Matcher filenameMatcher = LOG_FILENAME_PATTERN.matcher(entry.getFileName().toString());
+    if (filenameMatcher.matches()) {
+      String rotationDate = filenameMatcher.group("date");
+      if (rotationDate != null && !rotationDate.isBlank()) {
+        return Optional.of(Instant.parse(rotationDate + "T00:00:00.00Z"));
+      }
+    }
+    return Optional.empty();
+  }
+
+  private boolean delete(Path entry) {
+    try {
+      Files.deleteIfExists(entry);
+      logger.atInfo().log("Log file %s has been deleted.", entry);
+      return true;
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to delete log file %s", entry);
+    }
+    return false;
+  }
+
+  private void compress(Path src) {
+    Path dst = src.resolveSibling(src.getFileName() + ".gz");
+    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
+    try {
+      try (InputStream in = Files.newInputStream(src);
+          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
+        ByteStreams.copy(in, out);
+      }
+      tmp.toFile().setReadOnly();
+      try {
+        Files.move(tmp, dst);
+      } catch (IOException e) {
+        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
+      }
+      Files.delete(src);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Cannot compress %s", src);
+      try {
+        Files.deleteIfExists(tmp);
+      } catch (IOException e2) {
+        logger.atWarning().withCause(e2).log("Failed to delete temporary log file %s", tmp);
+      }
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "Log File Manager";
+  }
+}
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index aeaa1d6..ddf62fe 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
@@ -77,11 +78,11 @@
 
   /** Provides database connectivity and site path. */
   protected Injector createDbInjector() {
-    return createDbInjector(false);
+    return createDbInjector(false, GerritOptions.DEFAULT);
   }
 
   /** Provides database connectivity and site path. */
-  protected Injector createDbInjector(boolean enableMetrics) {
+  protected Injector createDbInjector(boolean enableMetrics, GerritOptions options) {
     List<Module> modules = new ArrayList<>();
 
     Module sitePathModule =
@@ -124,7 +125,15 @@
             bind(GerritRuntime.class).toInstance(getGerritRuntime());
           }
         });
-    Injector cfgInjector = Guice.createInjector(sitePathModule, configModule);
+    Module gerritOptionsModule =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(GerritOptions.class).toInstance(options);
+          }
+        };
+    modules.add(gerritOptionsModule);
+    Injector cfgInjector = Guice.createInjector(sitePathModule, configModule, gerritOptionsModule);
 
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
diff --git a/java/com/google/gerrit/server/AclInfoController.java b/java/com/google/gerrit/server/AclInfoController.java
new file mode 100644
index 0000000..1563ba3
--- /dev/null
+++ b/java/com/google/gerrit/server/AclInfoController.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/** Class to control when ACL infos should be collected and be returned to the user. */
+@Singleton
+public class AclInfoController {
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  AclInfoController(PermissionBackend permissionBackend) {
+    this.permissionBackend = permissionBackend;
+  }
+
+  public void enableAclLoggingIfUserCanViewAccess(TraceContext traceContext)
+      throws PermissionBackendException {
+    if (canViewAclInfos()) {
+      traceContext.enableAclLogging();
+    }
+  }
+
+  /**
+   * Returns message containing the ACL logs that have been collected for the request, {@link
+   * Optional#empty()} if ACL logging hasn't been turned on
+   */
+  public Optional<String> getAclInfoMessage() {
+    // ACL logging is only enabled if the user can view ACL infos. This is checked when ACL logging
+    // is turned on in enableAclLoggingIfUserCanViewAccess. Hence we can return ACL infos if ACL
+    // logging is on and do not need to check the permission again. We want to avoid re-checking the
+    // permission so that we do not need to handle PermissionBackendException.
+    if (!LoggingContext.getInstance().isAclLogging()) {
+      return Optional.empty();
+    }
+
+    ImmutableList<String> aclLogRecords = TraceContext.getAclLogRecords();
+    if (aclLogRecords.isEmpty()) {
+      aclLogRecords = ImmutableList.of("Found no rules that apply, so defaulting to no permission");
+    }
+
+    StringBuilder msgBuilder = new StringBuilder("ACL info:");
+    aclLogRecords.forEach(aclLogRecord -> msgBuilder.append("\n* ").append(aclLogRecord));
+    return Optional.of(msgBuilder.toString());
+  }
+
+  private boolean canViewAclInfos() throws PermissionBackendException {
+    return permissionBackend.currentUser().test(GlobalPermission.VIEW_ACCESS);
+  }
+}
diff --git a/java/com/google/gerrit/server/ChangeDraftUpdate.java b/java/com/google/gerrit/server/ChangeDraftUpdate.java
index d9cea31..a3b13e5 100644
--- a/java/com/google/gerrit/server/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/ChangeDraftUpdate.java
@@ -80,7 +80,9 @@
   default <UpdateT extends ChangeDraftUpdate> Optional<UpdateT> toOptionalChangeDraftUpdateSubtype(
       Class<UpdateT> subtype) {
     if (this.getClass().isAssignableFrom(subtype)) {
-      return Optional.of((UpdateT) this);
+      @SuppressWarnings("unchecked")
+      UpdateT update = (UpdateT) this;
+      return Optional.of(update);
     }
     return Optional.empty();
   }
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
index be6b4cd8..c523a29 100644
--- a/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -18,12 +18,14 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.args4j.AccountGroupIdHandler;
 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.ListTagSortOptionHandler;
 import com.google.gerrit.server.args4j.ObjectIdHandler;
 import com.google.gerrit.server.args4j.PatchSetIdHandler;
 import com.google.gerrit.server.args4j.ProjectHandler;
@@ -54,6 +56,7 @@
     registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
     registerOptionHandler(ProjectState.class, ProjectHandler.class);
     registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
+    registerOptionHandler(ListTagSortOption.class, ListTagSortOptionHandler.class);
   }
 
   private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
diff --git a/java/com/google/gerrit/server/DeadlineChecker.java b/java/com/google/gerrit/server/DeadlineChecker.java
index f41b1e3..9b7ffe6 100644
--- a/java/com/google/gerrit/server/DeadlineChecker.java
+++ b/java/com/google/gerrit/server/DeadlineChecker.java
@@ -180,12 +180,14 @@
     this.timeoutName =
         clientedProvidedTimeout
             .map(clientTimeout -> "client.timeout")
-            .orElse(
-                serverSideDeadline
-                    .map(serverDeadline -> serverDeadline.id() + ".timeout")
-                    .orElse("timeout"));
+            .orElseGet(
+                () ->
+                    serverSideDeadline
+                        .map(serverDeadline -> serverDeadline.id() + ".timeout")
+                        .orElse("timeout"));
     this.timeout =
-        clientedProvidedTimeout.orElse(serverSideDeadline.map(ServerDeadline::timeout).orElse(0L));
+        clientedProvidedTimeout.orElseGet(
+            () -> serverSideDeadline.map(ServerDeadline::timeout).orElse(0L));
     this.deadline = timeout > 0 ? Optional.of(start + timeout) : Optional.empty();
   }
 
diff --git a/java/com/google/gerrit/server/ExternalUser.java b/java/com/google/gerrit/server/ExternalUser.java
index 9680f3e..bd4271d 100644
--- a/java/com/google/gerrit/server/ExternalUser.java
+++ b/java/com/google/gerrit/server/ExternalUser.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.flogger.LazyArgs.lazy;
-
 import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -34,8 +31,6 @@
  * <p>This class is thread-safe.
  */
 public class ExternalUser extends CurrentUser {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     ExternalUser create(
         Collection<String> emailAddresses,
@@ -76,8 +71,6 @@
     synchronized (this) {
       if (effectiveGroups == null) {
         effectiveGroups = groupBackend.membershipsOf(this);
-        logger.atFinest().log(
-            "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
       }
     }
     return effectiveGroups;
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index d45d329..b069e39 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.common.flogger.LazyArgs.lazy;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
@@ -422,13 +421,9 @@
     if (effectiveGroups == null) {
       if (authConfig.isIdentityTrustable(state().externalIds())) {
         effectiveGroups = groupBackend.membershipsOf(this);
-        logger.atFinest().log(
-            "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
       } else {
         effectiveGroups = registeredGroups;
-        logger.atFinest().log(
-            "%s has a non-trusted identity, falling back to %s as known groups",
-            getLoggableName(), lazy(registeredGroups::getKnownGroups));
+        logger.atFinest().log("%s has a non-trusted identity", getLoggableName());
       }
     }
     return effectiveGroups;
diff --git a/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
index 5c0f8e4..55eda8b 100644
--- a/java/com/google/gerrit/server/LibModuleLoader.java
+++ b/java/com/google/gerrit/server/LibModuleLoader.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.options.AutoFlush;
@@ -58,7 +59,7 @@
       Method m =
           clazz.getMethod(
               "singleVersionWithExplicitVersions",
-              Map.class,
+              ImmutableMap.class,
               int.class,
               boolean.class,
               AutoFlush.class);
diff --git a/java/com/google/gerrit/server/PerformanceMetrics.java b/java/com/google/gerrit/server/PerformanceMetrics.java
new file mode 100644
index 0000000..ee989d5
--- /dev/null
+++ b/java/com/google/gerrit/server/PerformanceMetrics.java
@@ -0,0 +1,150 @@
+// 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;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.inject.Inject;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/** Performance logger that records the execution times as a metric. */
+public class PerformanceMetrics implements PerformanceLogger {
+  private static final String OPERATION_LATENCY_METRIC_NAME = "performance/operations";
+  private static final String OPERATION_COUNT_METRIC_NAME = "performance/operations_count";
+
+  private final ImmutableList<String> tracedOperations;
+  private final Timer3<String, String, String> operationsLatency;
+  private final Counter3<String, String, String> operationsCounter;
+  private final Timer3<String, String, String> operationsRequestLatency;
+
+  private final Map<MetricKey, Long> perRequestLatencyNanos = new HashMap<>();
+
+  @Inject
+  PerformanceMetrics(@GerritServerConfig Config cfg, MetricMaker metricMaker) {
+    this.tracedOperations =
+        ImmutableList.copyOf(cfg.getStringList("performance", "metric", "operation"));
+
+    Field<String> operationNameField =
+        Field.ofString(
+                "operation_name",
+                (metadataBuilder, fieldValue) -> metadataBuilder.operationName(fieldValue))
+            .description("The operation that was performed.")
+            .build();
+    Field<String> requestField =
+        Field.ofString("request", (metadataBuilder, fieldValue) -> {})
+            .description(
+                "The request for which the operation was performed"
+                    + " (format = '<request-type> <redacted-request-uri>').")
+            .build();
+    Field<String> pluginField =
+        Field.ofString(
+                "plugin", (metadataBuilder, fieldValue) -> metadataBuilder.pluginName(fieldValue))
+            .description("The name of the plugin that performed the operation.")
+            .build();
+
+    this.operationsLatency =
+        metricMaker
+            .newTimer(
+                OPERATION_LATENCY_METRIC_NAME,
+                new Description("Latency of performing operations")
+                    .setCumulative()
+                    .setUnit(Description.Units.MILLISECONDS),
+                operationNameField,
+                requestField,
+                pluginField)
+            .suppressLogging();
+    this.operationsCounter =
+        metricMaker.newCounter(
+            OPERATION_COUNT_METRIC_NAME,
+            new Description("Number of performed operations").setRate(),
+            operationNameField,
+            requestField,
+            pluginField);
+    this.operationsRequestLatency =
+        metricMaker
+            .newTimer(
+                OPERATION_LATENCY_METRIC_NAME,
+                new Description("Per request latency of performing operations")
+                    .setCumulative()
+                    .setUnit(Description.Units.MILLISECONDS),
+                operationNameField,
+                requestField,
+                pluginField)
+            .suppressLogging();
+  }
+
+  @Override
+  public void logNanos(String operation, long durationNanos, Instant endTime) {
+    logNanos(operation, durationNanos, endTime, /* metadata= */ null);
+  }
+
+  @Override
+  public void logNanos(
+      String operation, long durationNanos, Instant endTime, @Nullable Metadata metadata) {
+    if (!tracedOperations.contains(operation)) {
+      return;
+    }
+
+    String requestTag = TraceContext.getTag(TraceRequestListener.TAG_REQUEST).orElse("");
+    String pluginTag = TraceContext.getPluginTag().orElse("");
+    operationsLatency.record(operation, requestTag, pluginTag, durationNanos, TimeUnit.NANOSECONDS);
+    operationsCounter.increment(operation, requestTag, pluginTag);
+
+    perRequestLatencyNanos.compute(
+        MetricKey.create(operation, requestTag, pluginTag),
+        (metricKey, latencyNanos) ->
+            (latencyNanos == null) ? durationNanos : latencyNanos + durationNanos);
+  }
+
+  @Override
+  public void done() {
+    perRequestLatencyNanos.forEach(
+        (metricKey, latencyNanos) ->
+            operationsRequestLatency.record(
+                metricKey.operation(),
+                metricKey.requestTag(),
+                metricKey.pluginTag(),
+                latencyNanos,
+                TimeUnit.NANOSECONDS));
+    perRequestLatencyNanos.clear();
+  }
+
+  @AutoValue
+  abstract static class MetricKey {
+    abstract String operation();
+
+    abstract String requestTag();
+
+    abstract String pluginTag();
+
+    public static MetricKey create(String operation, String requestTag, String pluginTag) {
+      return new AutoValue_PerformanceMetrics_MetricKey(operation, requestTag, pluginTag);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/RequestConfig.java b/java/com/google/gerrit/server/RequestConfig.java
index f942c5e..f3d3e51 100644
--- a/java/com/google/gerrit/server/RequestConfig.java
+++ b/java/com/google/gerrit/server/RequestConfig.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -55,6 +57,27 @@
     return requestConfigs.build();
   }
 
+  public static ImmutableList<RequestConfig> parseTraceConfigs(Config cfg, String section) {
+    // To prevent that misconfigured tracing configs (e.g. empty configs) cause tracing of too many
+    // requests, require that at least one of the following match criteria have been specified:
+    // request URI pattern, account, project pattern
+    return parseConfigs(cfg, section).stream()
+        .filter(
+            requestConfig -> {
+              if (!requestConfig.requestUriPatterns().isEmpty()
+                  || !requestConfig.accountIds().isEmpty()
+                  || !requestConfig.projectPatterns().isEmpty()) {
+                return true;
+              }
+              logger.atWarning().log(
+                  "Ignoring tracing configuration %s because it is too broad (needs to set at"
+                      + " least one of: requestUriPattern, account, projectPattern)",
+                  section);
+              return false;
+            })
+        .collect(toImmutableList());
+  }
+
   private static ImmutableSet<String> parseRequestTypes(Config cfg, String section, String id) {
     return ImmutableSet.copyOf(cfg.getStringList(section, id, "requestType"));
   }
@@ -247,9 +270,8 @@
       }
     }
 
-    // For any match criteria (request type, request URI pattern, request query string pattern,
-    // header, account, project pattern) that was specified in the request config, at least one of
-    // the configured value matched the request.
+    // All specified match criteria (request type, request URI pattern, request query string
+    // pattern, header, account, project pattern) did match.
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/ServerStateProvider.java b/java/com/google/gerrit/server/ServerStateProvider.java
new file mode 100644
index 0000000..a0a465e
--- /dev/null
+++ b/java/com/google/gerrit/server/ServerStateProvider.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.MetadataInfo;
+
+/**
+ * Extension point to retrieve server state that should be included in the response of the {@link
+ * com.google.gerrit.server.restapi.config.GetServerInfo} REST endpoint.
+ */
+@ExtensionPoint
+public interface ServerStateProvider {
+  /**
+   * Returns metadata to populate {@link com.google.gerrit.extensions.common.ServerInfo#metadata}.
+   */
+  public ImmutableList<MetadataInfo> getMetadata();
+}
diff --git a/java/com/google/gerrit/server/TraceRequestListener.java b/java/com/google/gerrit/server/TraceRequestListener.java
index 6cc0982..f99cc2d 100644
--- a/java/com/google/gerrit/server/TraceRequestListener.java
+++ b/java/com/google/gerrit/server/TraceRequestListener.java
@@ -37,7 +37,7 @@
 
   @Inject
   TraceRequestListener(@GerritServerConfig Config cfg) {
-    this.traceConfigs = RequestConfig.parseConfigs(cfg, SECTION_TRACING);
+    this.traceConfigs = RequestConfig.parseTraceConfigs(cfg, SECTION_TRACING);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 4676be3..41026f8 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -153,7 +153,7 @@
    * @param project Project name.
    * @param revision Name of the revision (e.g. branch or commit ID)
    * @param hash SHA1 of revision.
-   * @param file File name.
+   * @param file File path (without leading slash)
    */
   public ImmutableList<WebLinkInfo> getFileLinks(
       String project, String revision, String hash, String file) {
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index e01b206..d269b71 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -125,7 +125,7 @@
 
   @Override
   public AccountState getEvenIfMissing(Account.Id accountId) {
-    return get(accountId).orElse(missing(accountId));
+    return get(accountId).orElseGet(() -> missing(accountId));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index f074522..cac515c 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -10,7 +10,7 @@
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
-// limitations under the Licens
+// limitations under the License
 
 package com.google.gerrit.server.account;
 
@@ -410,7 +410,8 @@
      * @return the builder
      */
     @CanIgnoreReturnValue
-    public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+    public Builder updateProjectWatches(
+        Map<ProjectWatchKey, ? extends Set<NotifyType>> projectWatches) {
       updatedProjectWatchesBuilder().putAll(projectWatches);
       return this;
     }
@@ -615,7 +616,8 @@
       }
 
       @Override
-      public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+      public Builder updateProjectWatches(
+          Map<ProjectWatchKey, ? extends Set<NotifyType>> projectWatches) {
         delegate.updateProjectWatches(projectWatches);
         return this;
       }
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index a037046..93b4cd3 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -98,7 +98,9 @@
    * @return limit according to {@link GlobalCapability#QUERY_LIMIT}.
    */
   public int getQueryLimit() {
-    return getRange(GlobalCapability.QUERY_LIMIT).getMax();
+    return user.isInternalUser()
+        ? Integer.MAX_VALUE
+        : getRange(GlobalCapability.QUERY_LIMIT).getMax();
   }
 
   /** Returns true if the user has a permission rule specifying the range. */
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index c260401..42687987 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -21,6 +21,9 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -90,7 +93,9 @@
   }
 
   public void fill() throws PermissionBackendException {
-    directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
+    try (TraceTimer timer = TraceContext.newTimer("Fill accounts", Metadata.empty())) {
+      directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
+    }
   }
 
   public void fill(Collection<? extends AccountInfo> infos) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 29f5a7c..51948f9 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureStatelessDelta;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
@@ -60,7 +61,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
@@ -236,7 +236,7 @@
           "Unable to deactivate account %s",
           authRequest
               .getUserName()
-              .orElse(" for external ID key " + authRequest.getExternalIdKey().get()));
+              .orElseGet(() -> " for external ID key " + authRequest.getExternalIdKey().get()));
     }
   }
 
@@ -269,7 +269,7 @@
   private void update(AuthRequest who, ExternalId extId)
       throws IOException, ConfigInvalidException, AccountException {
     IdentifiedUser user = userFactory.create(extId.accountId());
-    List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
+    List<ConfigureStatelessDelta> accountUpdates = new ArrayList<>();
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
@@ -311,7 +311,7 @@
               .update(
                   "Update Account on Login",
                   user.getAccountId(),
-                  AccountsUpdate.joinConsumers(accountUpdates));
+                  AccountsUpdate.joinDeltaConfigures(accountUpdates));
       if (!updatedAccount.isPresent()) {
         throw new StorageException("Account " + user.getAccountId() + " has been deleted");
       }
@@ -365,7 +365,8 @@
               + e.getDuplicateKey().get()
               + "\" to account "
               + newId
-              + "; external ID already in use.");
+              + "; external ID already in use.",
+          e);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 5f56aa3..9cc20d4 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -97,6 +97,7 @@
 
     accountBuilder.setStatus(get(accountConfig, KEY_STATUS));
     accountBuilder.setMetaId(metaId != null ? metaId.name() : null);
+    accountBuilder.setUniqueTag(accountBuilder.metaId());
     account = accountBuilder.build();
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 7aa25b6..344ed58 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -591,6 +591,7 @@
           .add(new BySelf())
           .add(new ByExactAccountId())
           .add(new ByEmail())
+          .add(new ByUsername())
           .build();
 
   private final AccountCache accountCache;
@@ -749,6 +750,13 @@
         input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_REVIEWERS)
+  public Result resolveExactIgnoreVisibility(String input)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input, exactSearchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
+  }
+
   public Result resolveAsUserIgnoreVisibility(CurrentUser asUser, String input)
       throws ConfigInvalidException, IOException {
     return resolveAsUserIgnoreVisibility(asUser, input, AccountResolver::isActive);
diff --git a/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
index 9629809..14b363b 100644
--- a/java/com/google/gerrit/server/account/AccountResource.java
+++ b/java/com/google/gerrit/server/account/AccountResource.java
@@ -99,6 +99,10 @@
     public Change getChange() {
       return change.getChange();
     }
+
+    public Change.Id getVirtualId() {
+      return change.getVirtualId();
+    }
   }
 
   public static class Star implements RestResource {
diff --git a/java/com/google/gerrit/server/account/AccountStateProvider.java b/java/com/google/gerrit/server/account/AccountStateProvider.java
new file mode 100644
index 0000000..aa6fbee
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountStateProvider.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.MetadataInfo;
+
+/**
+ * Extension point to retrieve account state that should be included in the response of the {@link
+ * com.google.gerrit.server.restapi.account.GetState} REST endpoint.
+ */
+@ExtensionPoint
+public interface AccountStateProvider {
+  /**
+   * Returns metadata to populate {@link
+   * com.google.gerrit.extensions.common.AccountStateInfo#metadata}.
+   */
+  public ImmutableList<MetadataInfo> getMetadata(Account.Id accountId);
+}
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 5951a73..5ec97ff 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -20,20 +20,23 @@
 import static java.lang.annotation.ElementType.PARAMETER;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.inject.BindingAnnotation;
 import java.io.IOException;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 import java.util.List;
 import java.util.Optional;
-import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 
@@ -49,6 +52,7 @@
  * <p>See the implementing classes for more information.
  */
 public abstract class AccountsUpdate {
+  /** Loader for {@link AccountsUpdate}s. */
   public interface AccountsUpdateLoader {
     /**
      * Creates an {@code AccountsUpdate} which uses the identity of the specified user as author for
@@ -87,24 +91,74 @@
   public static class UpdateArguments {
     public final String message;
     public final Account.Id accountId;
-    public final AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState;
+    public final ConfigureDeltaFromStateAndContext configureDelta;
 
     public UpdateArguments(
-        String message,
-        Account.Id accountId,
-        AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState) {
+        String message, Account.Id accountId, ConfigureStatelessDelta configureDelta) {
+      this(message, accountId, withContext(configureDelta));
+    }
+
+    public UpdateArguments(
+        String message, Account.Id accountId, ConfigureDeltaFromState configureDelta) {
+      this(message, accountId, withContext(configureDelta));
+    }
+
+    public UpdateArguments(
+        String message, Account.Id accountId, ConfigureDeltaFromStateAndContext configureDelta) {
       this.message = message;
       this.accountId = accountId;
-      this.configureDeltaFromState = configureDeltaFromState;
+      this.configureDelta = configureDelta;
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("message", message)
+          .add("accountId", accountId)
+          .toString();
     }
   }
 
   /**
-   * Account updates are commonly performed by evaluating the current account state and creating a
-   * delta to be applied to it in a later step. This is done by implementing this interface.
+   * Storage readers/writers which are accessible through {@link ConfigureDeltaFromStateAndContext}.
    *
-   * <p>If the current account state is not needed, use a {@link Consumer} of {@link
-   * com.google.gerrit.server.account.AccountDelta.Builder} instead.
+   * <p>If you need to perform extra reads/writes during the update, prefer using these, to avoid
+   * mishmash between different storage systems where multiple ones are supported. If you need an
+   * accessor which is not yet here, please do add it.
+   */
+  public interface InUpdateStorageAccessors {
+    ExternalIds externalIdsReader();
+  }
+
+  /**
+   * The most basic interface for updating the account delta, providing no state nor context.
+   *
+   * <p>Account updates that do not need to know the current account state, nor read/write any extra
+   * data during the update, should use this interface.
+   *
+   * <p>If the above capabilities are needed, use {@link ConfigureDeltaFromState} or {@link
+   * ConfigureDeltaFromStateAndContext} instead.
+   */
+  @FunctionalInterface
+  public interface ConfigureStatelessDelta {
+    /**
+     * Configures an {@link com.google.gerrit.server.account.AccountDelta.Builder} with changes to
+     * the account.
+     *
+     * @param delta the changes to be applied
+     */
+    void configure(AccountDelta.Builder delta);
+  }
+
+  /**
+   * Interface for updating the account delta, providing the current state.
+   *
+   * <p>Account updates are commonly performed by evaluating the current account state and creating
+   * a delta to be applied to it in a later step. This is done by implementing this interface.
+   *
+   * <p>If the current account state is not needed, use {@link ConfigureStatelessDelta} instead.
+   * Alternatively, if you need to perform extra storage reads/writes during the update, use {@link
+   * ConfigureDeltaFromStateAndContext}.
    */
   @FunctionalInterface
   public interface ConfigureDeltaFromState {
@@ -118,42 +172,68 @@
     void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
   }
 
-  /** Returns an instance that runs all specified consumers. */
-  public static ConfigureDeltaFromState joinConsumers(
-      List<Consumer<AccountDelta.Builder>> consumers) {
-    return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
+  /**
+   * Interface for updating the account delta, providing the current state and storage accessors.
+   *
+   * <p>Account updates which need to perform extra storage reads/writes during the update, should
+   * use this interface.
+   *
+   * <p>If storage accessors are not needed, use {@link ConfigureStatelessDelta} or {@link
+   * ConfigureDeltaFromState} instead.
+   */
+  @FunctionalInterface
+  public interface ConfigureDeltaFromStateAndContext {
+    /**
+     * Receives {@link InUpdateStorageAccessors} for reading/modifying data on the respective
+     * storage system, as well as the current {@link AccountState} (which is immutable). Configures
+     * an {@link com.google.gerrit.server.account.AccountDelta.Builder} with changes to the account.
+     *
+     * @param inUpdateStorageAccessors storage accessor which have the context of the update (e.g.,
+     *     use the same storage system as the calling updater)
+     * @param accountState the state of the account that is being updated
+     * @param delta the changes to be applied
+     * @see InUpdateStorageAccessors
+     */
+    void configure(
+        InUpdateStorageAccessors inUpdateStorageAccessors,
+        AccountState accountState,
+        AccountDelta.Builder delta)
+        throws IOException;
   }
 
-  static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
-    return (a, u) -> consumer.accept(u);
+  /** Returns an instance that runs all specified consumers. */
+  public static ConfigureStatelessDelta joinDeltaConfigures(
+      List<ConfigureStatelessDelta> deltaConfigures) {
+    return (update) -> deltaConfigures.forEach(c -> c.configure(update));
   }
 
   protected final PersonIdent committerIdent;
   protected final PersonIdent authorIdent;
 
   protected final Optional<IdentifiedUser> currentUser;
+  private final InUpdateStorageAccessors inUpdateStorageAccessors;
 
-  protected AccountsUpdate(PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+  @SuppressWarnings("Convert2Lambda")
+  protected AccountsUpdate(
+      PersonIdent serverIdent, Optional<IdentifiedUser> user, ExternalIds externalIdsReader) {
     this.currentUser = user;
     this.committerIdent = serverIdent;
     this.authorIdent = createPersonIdent(serverIdent, user);
-  }
-
-  /**
-   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
-   * instead, i.e. the update does not depend on the current account state (which, for insertion,
-   * would only contain the account ID).
-   */
-  @CanIgnoreReturnValue
-  public AccountState insert(
-      String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
-      throws IOException, ConfigInvalidException {
-    return insert(message, accountId, AccountsUpdate.fromConsumer(init));
+    this.inUpdateStorageAccessors =
+        new InUpdateStorageAccessors() {
+          @Override
+          public ExternalIds externalIdsReader() {
+            return externalIdsReader;
+          }
+        };
   }
 
   /**
    * Inserts a new account.
    *
+   * <p>If the current account state is not needed, use {@link #insert(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
+   *
    * @param message commit message for the account creation, must not be {@code null or empty}
    * @param accountId ID of the new account
    * @param init to populate the new account
@@ -162,19 +242,36 @@
    * @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
    */
+  @CanIgnoreReturnValue
   public abstract AccountState insert(
-      String message, Account.Id accountId, ConfigureDeltaFromState init)
+      String message, Account.Id accountId, ConfigureDeltaFromStateAndContext init)
       throws IOException, ConfigInvalidException;
 
   /**
-   * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
-   * instead, i.e. the update does not depend on the current account state.
+   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+   * ConfigureDeltaFromState} instead. I.e. the update does not require any extra storage
+   * reads/writes, except for the current {@link AccountState}.
+   *
+   * <p>If the current account state is not needed as well, use {@link #insert(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
    */
   @CanIgnoreReturnValue
-  public Optional<AccountState> update(
-      String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
+  public final AccountState insert(
+      String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
-    return update(message, accountId, AccountsUpdate.fromConsumer(update));
+    return insert(message, accountId, withContext(init));
+  }
+
+  /**
+   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+   * ConfigureStatelessDelta} instead. I.e. the update does not depend on the current account state,
+   * nor requires any extra storage reads/writes.
+   */
+  @CanIgnoreReturnValue
+  public final AccountState insert(
+      String message, Account.Id accountId, ConfigureStatelessDelta init)
+      throws IOException, ConfigInvalidException {
+    return insert(message, accountId, withContext(init));
   }
 
   /**
@@ -182,10 +279,12 @@
    *
    * <p>Changing the registration date of an account is not supported.
    *
+   * <p>If the current account state is not needed, use {@link #update(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
+   *
    * @param message commit message for the account update, must not be {@code null or empty}
    * @param accountId ID of the account
-   * @param configureDeltaFromState deltaBuilder to update the account, only invoked if the account
-   *     exists
+   * @param configureDelta deltaBuilder to update the account, only invoked if the account exists
    * @return the updated account, {@link Optional#empty} if the account doesn't exist
    * @throws IOException if updating the user branch fails due to an IO error
    * @throws LockFailureException if updating the user branch still fails due to concurrent updates
@@ -193,15 +292,41 @@
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
   @CanIgnoreReturnValue
-  public Optional<AccountState> update(
-      String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
+  public final Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromStateAndContext configureDelta)
       throws IOException, ConfigInvalidException {
-    return updateBatch(
-            ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
+    return updateBatch(ImmutableList.of(new UpdateArguments(message, accountId, configureDelta)))
         .get(0);
   }
 
   /**
+   * Like {@link #update(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+   * ConfigureDeltaFromState} instead. I.e. the update does not require any extra storage
+   * reads/writes, except for the current {@link AccountState}.
+   *
+   * <p>If the current account state is not needed as well, use {@link #update(String, Account.Id,
+   * ConfigureStatelessDelta)} instead.
+   */
+  @CanIgnoreReturnValue
+  public final Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromState configureDelta)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, withContext(configureDelta));
+  }
+
+  /**
+   * Like {@link #update(String, Account.Id, ConfigureDeltaFromStateAndContext)} , but using {@link
+   * ConfigureStatelessDelta} instead. I.e. the update does not depend on the current account state,
+   * nor requires any extra storage reads/writes.
+   */
+  @CanIgnoreReturnValue
+  public final Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureStatelessDelta configureDelta)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, withContext(configureDelta));
+  }
+
+  /**
    * Updates multiple different accounts atomically. This will only store a single new value (aka
    * set of all external IDs of the host) in the external ID cache, which is important for storage
    * economy. All {@code updates} must be for different accounts.
@@ -212,7 +337,7 @@
    * together have this property) will always prevent the entire batch from being executed.
    */
   @CanIgnoreReturnValue
-  public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+  public final ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
       throws IOException, ConfigInvalidException {
     checkArgument(
         updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
@@ -231,9 +356,33 @@
   public abstract void delete(String message, Account.Id accountId)
       throws IOException, ConfigInvalidException;
 
-  protected abstract ImmutableList<Optional<AccountState>> executeUpdates(
+  @VisibleForTesting // productionVisibility: protected
+  public abstract ImmutableList<Optional<AccountState>> executeUpdates(
       List<UpdateArguments> updates) throws ConfigInvalidException, IOException;
 
+  /**
+   * Intended for internal usage only. This is public because some implementations are calling this
+   * method for other instances.
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public final void configureDelta(
+      ConfigureDeltaFromStateAndContext configureDelta,
+      AccountState accountState,
+      AccountDelta.Builder delta)
+      throws IOException {
+    configureDelta.configure(inUpdateStorageAccessors, accountState, delta);
+  }
+
+  private static ConfigureDeltaFromStateAndContext withContext(
+      ConfigureDeltaFromState configureDelta) {
+    return (unusedAccessors, accountState, delta) -> configureDelta.configure(accountState, delta);
+  }
+
+  private static ConfigureDeltaFromStateAndContext withContext(
+      ConfigureStatelessDelta configureDelta) {
+    return (unusedAccessors, unusedAccountState, delta) -> configureDelta.configure(delta);
+  }
+
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
     return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index e167a23..e6e2735 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -114,7 +114,8 @@
               .setDisplayName(Strings.nullToEmpty(account.displayName()))
               .setPreferredEmail(Strings.nullToEmpty(account.preferredEmail()))
               .setStatus(Strings.nullToEmpty(account.status()))
-              .setMetaId(Strings.nullToEmpty(account.metaId()));
+              .setMetaId(Strings.nullToEmpty(account.metaId()))
+              .setUniqueTag(Strings.nullToEmpty(account.uniqueTag()));
       serialized.setAccount(accountProto);
 
       for (Map.Entry<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> watch :
@@ -145,7 +146,7 @@
     public CachedAccountDetails deserialize(byte[] in) {
       Cache.AccountDetailsProto proto =
           Protos.parseUnchecked(Cache.AccountDetailsProto.parser(), in);
-      Account account =
+      Account.Builder builder =
           Account.builder(
                   Account.id(proto.getAccount().getId()),
                   Instant.ofEpochMilli(proto.getAccount().getRegisteredOn()))
@@ -155,7 +156,11 @@
               .setInactive(proto.getAccount().getInactive())
               .setStatus(Strings.emptyToNull(proto.getAccount().getStatus()))
               .setMetaId(Strings.emptyToNull(proto.getAccount().getMetaId()))
-              .build();
+              .setUniqueTag(Strings.emptyToNull(proto.getAccount().getUniqueTag()));
+      if (Strings.isNullOrEmpty(builder.uniqueTag())) {
+        builder.setUniqueTag(builder.metaId());
+      }
+      Account account = builder.build();
 
       ImmutableMap.Builder<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches = ImmutableMap.builder();
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 914bdd2..43270df 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -23,11 +23,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto;
@@ -44,9 +46,11 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -61,6 +65,8 @@
   private static final String EXTERNAL_NAME = "groups_external";
   private static final String PERSISTED_EXTERNAL_NAME = "groups_external_persisted";
 
+  private final IndexConfig indexConfig;
+
   public static Module module() {
     return new CacheModule() {
       @Override
@@ -114,10 +120,12 @@
           LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember,
       @Named(PARENT_GROUPS_NAME)
           LoadingCache<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> parentGroups,
-      @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
+      @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external,
+      IndexConfig indexConfig) {
     this.groupsWithMember = groupsWithMember;
     this.parentGroups = parentGroups;
     this.external = external;
+    this.indexConfig = indexConfig;
   }
 
   @Override
@@ -144,7 +152,10 @@
   public Collection<AccountGroup.UUID> parentGroupsOf(Set<AccountGroup.UUID> groupIds) {
     try {
       Set<AccountGroup.UUID> parents = new HashSet<>();
-      parentGroups.getAll(groupIds).values().forEach(p -> parents.addAll(p));
+      for (List<AccountGroup.UUID> groupIdsBatch :
+          Lists.partition(new ArrayList<>(groupIds), indexConfig.maxTerms())) {
+        parentGroups.getAll(groupIdsBatch).values().forEach(p -> parents.addAll(p));
+      }
       return parents;
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot load included groups");
diff --git a/java/com/google/gerrit/server/account/HashedPassword.java b/java/com/google/gerrit/server/account/HashedPassword.java
index 0911550..7a7c35b 100644
--- a/java/com/google/gerrit/server/account/HashedPassword.java
+++ b/java/com/google/gerrit/server/account/HashedPassword.java
@@ -136,6 +136,6 @@
 
   public boolean checkPassword(String password) {
     // Constant-time comparison, because we're paranoid.
-    return Arrays.areEqual(hashPassword(password, salt, cost, nullTerminate), hashed);
+    return Arrays.constantTimeAreEqual(hashPassword(password, salt, cost, nullTerminate), hashed);
   }
 }
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 86132d3..341b493 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.ConvertibleToProto;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig;
@@ -79,6 +80,7 @@
  */
 public class ProjectWatches {
   @AutoValue
+  @ConvertibleToProto
   public abstract static class ProjectWatchKey {
 
     public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index db030f9..a05baf5 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
@@ -63,41 +66,43 @@
 
   @Override
   public boolean isServiceUser(Account.Id user) {
-    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
-    if (!maybeGroup.isPresent()) {
+    try (TraceTimer timer = TraceContext.newTimer("isServiceUser", Metadata.empty())) {
+      Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
+      if (!maybeGroup.isPresent()) {
+        return false;
+      }
+      List<AccountGroup.UUID> toTraverse = new ArrayList<>();
+      toTraverse.add(maybeGroup.get().getGroupUUID());
+      Set<AccountGroup.UUID> seen = new HashSet<>();
+      while (!toTraverse.isEmpty()) {
+        InternalGroup currentGroup =
+            groupCache
+                .get(toTraverse.remove(0))
+                .orElseThrow(() -> new IllegalStateException("invalid subgroup"));
+        if (seen.contains(currentGroup.getGroupUUID())) {
+          logger.atFine().log(
+              "Skipping %s because it's a cyclic subgroup", currentGroup.getGroupUUID());
+          continue;
+        }
+        seen.add(currentGroup.getGroupUUID());
+        if (currentGroup.getMembers().contains(user)) {
+          // The user is a member of the 'Service Users' group or a subgroup.
+          return true;
+        }
+        boolean hasExternalSubgroup =
+            currentGroup.getSubgroups().stream().anyMatch(g -> !internalGroupBackend.handles(g));
+        if (hasExternalSubgroup) {
+          // 'Service Users or a subgroup of Service User' contains an external subgroup, so we have
+          // to default to the more expensive evaluation of getting all of the user's group
+          // memberships.
+          return identifiedUserFactory
+              .create(user)
+              .getEffectiveGroups()
+              .contains(maybeGroup.get().getGroupUUID());
+        }
+        toTraverse.addAll(currentGroup.getSubgroups());
+      }
       return false;
     }
-    List<AccountGroup.UUID> toTraverse = new ArrayList<>();
-    toTraverse.add(maybeGroup.get().getGroupUUID());
-    Set<AccountGroup.UUID> seen = new HashSet<>();
-    while (!toTraverse.isEmpty()) {
-      InternalGroup currentGroup =
-          groupCache
-              .get(toTraverse.remove(0))
-              .orElseThrow(() -> new IllegalStateException("invalid subgroup"));
-      if (seen.contains(currentGroup.getGroupUUID())) {
-        logger.atFine().log(
-            "Skipping %s because it's a cyclic subgroup", currentGroup.getGroupUUID());
-        continue;
-      }
-      seen.add(currentGroup.getGroupUUID());
-      if (currentGroup.getMembers().contains(user)) {
-        // The user is a member of the 'Service Users' group or a subgroup.
-        return true;
-      }
-      boolean hasExternalSubgroup =
-          currentGroup.getSubgroups().stream().anyMatch(g -> !internalGroupBackend.handles(g));
-      if (hasExternalSubgroup) {
-        // 'Service Users or a subgroup of Service User' contains an external subgroup, so we have
-        // to default to the more expensive evaluation of getting all of the user's group
-        // memberships.
-        return identifiedUserFactory
-            .create(user)
-            .getEffectiveGroups()
-            .contains(maybeGroup.get().getGroupUUID());
-      }
-      toTraverse.addAll(currentGroup.getSubgroups());
-    }
-    return false;
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index a23e7bc..886fe70 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -28,6 +28,12 @@
  * cache is up to date.
  *
  * <p>All returned collections are unmodifiable.
+ *
+ * <p>NOTE: Modules which bind {@link ExternalIdCache} by using modules other than {@link
+ * com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl.ExternalIdCacheBindingModule},
+ * should also provide an {@code Optional<}{@link
+ * com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl}{@code >}
+ * binding.
  */
 public interface ExternalIdCache {
   Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
index 8e53277..fd19fcc 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 
@@ -32,6 +34,12 @@
       protected void configure() {
         bind(ExternalIdCache.class).to(DisabledExternalIdCache.class);
       }
+
+      @Provides
+      @Singleton
+      Optional<ExternalIdCacheImpl> provideNoteDbExternalIdCacheImpl() {
+        return Optional.empty();
+      }
     };
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
index dbfe205..20c94eb 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
@@ -14,27 +14,86 @@
 
 package com.google.gerrit.server.account.externalids.storage.notedb;
 
+import static com.google.inject.Scopes.SINGLETON;
+
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.Provides;
 import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Optional;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 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 {
+/**
+ * Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. *
+ *
+ * <p>This class should be bounded as a Singleton. However, due to internal limitations in Google,
+ * it cannot be marked as a singleton. The common installation pattern should therefore be:
+ *
+ * <pre>{@code
+ * * install(new ExternalIdCacheModule());
+ * * install(new ExternalIdCacheBindingModule());
+ * *
+ * }</pre>
+ */
+public class ExternalIdCacheImpl implements ExternalIdCache {
   public static final String CACHE_NAME = "external_ids_map";
 
+  public static class ExternalIdCacheModule extends CacheModule {
+    @Override
+    protected void configure() {
+      persist(CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
+          // The cached data is potentially pretty large and we are always only interested
+          // in the latest value. However, due to a race condition, it is possible for different
+          // threads to observe different values of the meta ref, and hence request different keys
+          // from the cache. Extend the cache size by 1 to cover this case, but expire the extra
+          // object after a short period of time, since it may be a potentially large amount of
+          // memory.
+          // When loading a new value because the primary data advanced, we want to leverage the old
+          // cache state to recompute only what changed. This doesn't affect cache size though as
+          // Guava calls the loader first and evicts later on.
+          .maximumWeight(2)
+          .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
+          .diskLimit(-1)
+          .version(1)
+          .keySerializer(ObjectIdCacheSerializer.INSTANCE)
+          .valueSerializer(AllExternalIds.Serializer.INSTANCE);
+    }
+  }
+
+  public static class ExternalIdCacheBindingModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class).in(SINGLETON);
+    }
+
+    /**
+     * Used by {@link ExternalIdsNoteDbImpl}. Modules which bind {@link ExternalIdCache} by using
+     * modules other than {@link ExternalIdCacheBindingModule}, should also provide an {@code
+     * Optional<ExternalIdCacheImpl>} binding.
+     */
+    @Provides
+    @Singleton
+    Optional<ExternalIdCacheImpl> provideNoteDbExternalIdCacheImpl(
+        ExternalIdCacheImpl externalIdCache) {
+      return Optional.of(externalIdCache);
+    }
+  }
+
   private final Cache<ObjectId, AllExternalIds> extIdsByAccount;
   private final ExternalIdReader externalIdReader;
   private final ExternalIdCacheLoader externalIdCacheLoader;
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheModule.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheModule.java
deleted file mode 100644
index aca0e1a..0000000
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheModule.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids.storage.notedb;
-
-import com.google.gerrit.server.account.externalids.ExternalIdCache;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
-import com.google.inject.TypeLiteral;
-import java.time.Duration;
-import org.eclipse.jgit.lib.ObjectId;
-
-public class ExternalIdCacheModule extends CacheModule {
-  @Override
-  protected void configure() {
-    persist(ExternalIdCacheImpl.CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
-        // The cached data is potentially pretty large and we are always only interested
-        // in the latest value. However, due to a race condition, it is possible for different
-        // threads to observe different values of the meta ref, and hence request different keys
-        // from the cache. Extend the cache size by 1 to cover this case, but expire the extra
-        // object after a short period of time, since it may be a potentially large amount of
-        // memory.
-        // When loading a new value because the primary data advanced, we want to leverage the old
-        // cache state to recompute only what changed. This doesn't affect cache size though as
-        // Guava calls the loader first and evicts later on.
-        .maximumWeight(2)
-        .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .diskLimit(-1)
-        .version(1)
-        .keySerializer(ObjectIdCacheSerializer.INSTANCE)
-        .valueSerializer(AllExternalIds.Serializer.INSTANCE);
-
-    bind(ExternalIdCacheImpl.class);
-    bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
-  }
-}
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
index f0632e4..6137884 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
@@ -41,14 +41,11 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.logging.CallerFinder;
-import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -361,7 +358,6 @@
   private final Counter0 updateCount;
   private final Repository repo;
   private final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
-  private final CallerFinder callerFinder;
   private final ExternalIdFactoryNoteDbImpl externalIdFactory;
 
   private NoteMap noteMap;
@@ -415,19 +411,6 @@
     this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
     this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
     this.upsertPreprocessors = upsertPreprocessors;
-    this.callerFinder =
-        CallerFinder.builder()
-            // 1. callers that come through ExternalIds
-            .addTarget(ExternalIds.class)
-
-            // 2. callers that come through AccountsUpdate
-            .addTarget(AccountsUpdate.class)
-            .addIgnoredPackage("com.github.rholder.retry")
-            .addIgnoredClass(RetryHelper.class)
-
-            // 3. direct callers
-            .addTarget(ExternalIdNotes.class)
-            .build();
     this.externalIdFactory = externalIdFactory;
     this.isUserNameCaseInsensitiveMigrationMode = isUserNameCaseInsensitiveMigrationMode;
   }
@@ -852,8 +835,6 @@
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
-      logger.atFine().log(
-          "Reading external ID note map (caller: %s)", callerFinder.findCallerLazy());
       noteMap = NoteMap.read(reader, revision);
     } else {
       noteMap = NoteMap.newEmptyMap();
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java
index 7a2945c..4c26442 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AuthConfig;
@@ -47,21 +46,15 @@
   @Inject
   ExternalIdsNoteDbImpl(
       ExternalIdReader externalIdReader,
-      ExternalIdCache externalIdCache,
+      Optional<ExternalIdCacheImpl> externalIdCacheImpl,
       ExternalIdKeyFactory externalIdKeyFactory,
       AuthConfig authConfig) {
     this.externalIdReader = externalIdReader;
-    if (externalIdCache instanceof ExternalIdCacheImpl) {
-      this.externalIdCache = (ExternalIdCacheImpl) externalIdCache;
-    } else if (externalIdCache instanceof DisabledExternalIdCache) {
-      // Supported case for testing only. Non of the disabled cache methods should be called, so
-      // it's safe to not assign the var.
-      this.externalIdCache = null;
-    } else {
-      throw new IllegalStateException(
-          "The cache provided in ExternalIdsNoteDbImpl should be either ExternalIdCacheImpl or"
-              + " DisabledExternalIdCache");
-    }
+    this.externalIdCache =
+        externalIdCacheImpl.orElse(
+            // Supported case for tests or Google implementation. None of the disabled cache methods
+            // should be called from these flows, so it's safe to not assign the var.
+            null);
     this.externalIdKeyFactory = externalIdKeyFactory;
     this.authConfig = authConfig;
   }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
index 9253133..c861bc0 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
@@ -16,6 +16,8 @@
 
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbReadStorageModule;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGeneratorImpl;
 import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
 
@@ -25,5 +27,6 @@
     install(new ExternalIdNoteDbReadStorageModule());
 
     bind(Accounts.class).to(AccountsNoteDbImpl.class).in(Singleton.class);
+    bind(MessageIdGenerator.class).to(MessageIdGeneratorImpl.class);
   }
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index ad3681d..edc8707 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -61,7 +61,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import java.util.function.Consumer;
 import javax.inject.Inject;
 import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -83,8 +82,9 @@
  * com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState}. The account updater
  * reads the current {@link AccountState} and prepares updates to the account by calling setters on
  * the provided {@link com.google.gerrit.server.account.AccountDelta.Builder}. If the current
- * account state is of no interest the caller may also provide a {@link Consumer} for {@link
- * com.google.gerrit.server.account.AccountDelta.Builder} instead of the account updater.
+ * account state is of no interest the caller may also provide a {@link
+ * com.google.gerrit.server.account.AccountsUpdate.ConfigureStatelessDelta} instead of the account
+ * updater.
  *
  * <p>The provided commit message is used for the update of the user branch. Using a precise and
  * unique commit message allows to identify the code from which an update was made when looking at a
@@ -272,7 +272,7 @@
       PersonIdent committerIdent,
       Runnable afterReadRevision,
       Runnable beforeCommit) {
-    super(committerIdent, currentUser);
+    super(committerIdent, currentUser, externalIds);
     this.repoManager = requireNonNull(repoManager, "repoManager");
     this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
     this.allUsersName = requireNonNull(allUsersName, "allUsersName");
@@ -286,7 +286,8 @@
   }
 
   @Override
-  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
+  public AccountState insert(
+      String message, Account.Id accountId, ConfigureDeltaFromStateAndContext init)
       throws IOException, ConfigInvalidException {
     return execute(
             ImmutableList.of(
@@ -295,7 +296,7 @@
                   Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-                  init.configure(accountState, deltaBuilder);
+                  configureDelta(init, accountState, deltaBuilder);
 
                   AccountDelta accountDelta = deltaBuilder.build();
                   accountConfig.setAccountDelta(accountDelta);
@@ -315,8 +316,7 @@
   public void delete(String message, Account.Id accountId)
       throws IOException, ConfigInvalidException {
     ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
-    Consumer<AccountDelta.Builder> delta =
-        deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
+    ConfigureStatelessDelta delta = deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
     update(message, accountId, delta);
   }
 
@@ -332,7 +332,7 @@
       }
 
       AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
+      configureDelta(updateArguments.configureDelta, accountState.get(), deltaBuilder);
 
       AccountDelta delta = deltaBuilder.build();
       updateExternalIdNotes(
@@ -380,7 +380,8 @@
   }
 
   @Override
-  protected ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
+  @VisibleForTesting
+  public ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
       throws ConfigInvalidException, IOException {
     return execute(updates.stream().map(this::createExecutableUpdate).collect(toImmutableList()));
   }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 55aa5d7..5defc26 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountStateInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -72,6 +73,7 @@
 import com.google.gerrit.server.restapi.account.GetGroups;
 import com.google.gerrit.server.restapi.account.GetPreferences;
 import com.google.gerrit.server.restapi.account.GetSshKeys;
+import com.google.gerrit.server.restapi.account.GetState;
 import com.google.gerrit.server.restapi.account.GetWatchedProjects;
 import com.google.gerrit.server.restapi.account.Index;
 import com.google.gerrit.server.restapi.account.PostWatchedProjects;
@@ -100,6 +102,7 @@
   private final AccountResource account;
   private final ChangesCollection changes;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final GetState getState;
   private final GetDetail getDetail;
   private final GetAvatar getAvatar;
   private final GetPreferences getPreferences;
@@ -142,6 +145,7 @@
   AccountApiImpl(
       AccountLoader.Factory ailf,
       ChangesCollection changes,
+      GetState getState,
       GetDetail getDetail,
       GetAvatar getAvatar,
       GetPreferences getPreferences,
@@ -183,6 +187,7 @@
     this.account = account;
     this.accountLoaderFactory = ailf;
     this.changes = changes;
+    this.getState = getState;
     this.getDetail = getDetail;
     this.getAvatar = getAvatar;
     this.getPreferences = getPreferences;
@@ -244,6 +249,15 @@
   }
 
   @Override
+  public AccountStateInfo state() throws RestApiException {
+    try {
+      return getState.apply(account).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get state", e);
+    }
+  }
+
+  @Override
   public boolean getActive() throws RestApiException {
     try {
       Response<String> result = getActive.apply(account);
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index c76eeeb..bdabcbd 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -16,10 +16,12 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditIdentityType;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.changes.RebaseChangeEditInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -150,9 +152,14 @@
 
   @Override
   public void rebase() throws RestApiException {
+    rebase(new RebaseChangeEditInput());
+  }
+
+  @Override
+  @CanIgnoreReturnValue
+  public EditInfo rebase(RebaseChangeEditInput input) throws RestApiException {
     try {
-      @SuppressWarnings("unused")
-      var unused = rebaseChangeEdit.apply(changeResource, null);
+      return rebaseChangeEdit.apply(changeResource, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase change edit", e);
     }
diff --git a/java/com/google/gerrit/server/api/config/ConfigModule.java b/java/com/google/gerrit/server/api/config/ConfigModule.java
index 9340ae1..0eae2ad 100644
--- a/java/com/google/gerrit/server/api/config/ConfigModule.java
+++ b/java/com/google/gerrit/server/api/config/ConfigModule.java
@@ -23,5 +23,7 @@
   protected void configure() {
     bind(Config.class).to(ConfigImpl.class);
     bind(Server.class).to(ServerImpl.class);
+
+    factory(ExperimentApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java b/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java
new file mode 100644
index 0000000..7eacf20
--- /dev/null
+++ b/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.config;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.config.ExperimentApi;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.ExperimentResource;
+import com.google.gerrit.server.restapi.config.GetExperiment;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class ExperimentApiImpl implements ExperimentApi {
+  interface Factory {
+    ExperimentApiImpl create(ExperimentResource r);
+  }
+
+  private final ExperimentResource experiment;
+  private final GetExperiment getExperiment;
+
+  @Inject
+  ExperimentApiImpl(GetExperiment getExperiment, @Assisted ExperimentResource r) {
+    this.getExperiment = getExperiment;
+    this.experiment = r;
+  }
+
+  @Override
+  public ExperimentInfo get() throws RestApiException {
+    try {
+      return getExperiment.apply(experiment).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get experiment", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
index ab40ec8..3928387 100644
--- a/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -16,22 +16,28 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ExperimentApi;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ExperimentInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.restapi.config.CheckConsistency;
+import com.google.gerrit.server.restapi.config.ExperimentsCollection;
 import com.google.gerrit.server.restapi.config.GetDiffPreferences;
 import com.google.gerrit.server.restapi.config.GetEditPreferences;
 import com.google.gerrit.server.restapi.config.GetPreferences;
 import com.google.gerrit.server.restapi.config.GetServerInfo;
+import com.google.gerrit.server.restapi.config.ListExperiments;
 import com.google.gerrit.server.restapi.config.ListTopMenus;
 import com.google.gerrit.server.restapi.config.SetDiffPreferences;
 import com.google.gerrit.server.restapi.config.SetEditPreferences;
@@ -52,6 +58,9 @@
   private final GetServerInfo getServerInfo;
   private final Provider<CheckConsistency> checkConsistency;
   private final ListTopMenus listTopMenus;
+  private final ExperimentApiImpl.Factory experimentApi;
+  private final ExperimentsCollection experimentsCollection;
+  private final Provider<ListExperiments> listExperimentsProvider;
 
   @Inject
   ServerImpl(
@@ -63,7 +72,10 @@
       SetEditPreferences setEditPreferences,
       GetServerInfo getServerInfo,
       Provider<CheckConsistency> checkConsistency,
-      ListTopMenus listTopMenus) {
+      ListTopMenus listTopMenus,
+      ExperimentApiImpl.Factory experimentApi,
+      ExperimentsCollection experimentsCollection,
+      Provider<ListExperiments> listExperimentsProvider) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
@@ -73,6 +85,9 @@
     this.getServerInfo = getServerInfo;
     this.checkConsistency = checkConsistency;
     this.listTopMenus = listTopMenus;
+    this.experimentApi = experimentApi;
+    this.experimentsCollection = experimentsCollection;
+    this.listExperimentsProvider = listExperimentsProvider;
   }
 
   @Override
@@ -163,4 +178,37 @@
       throw asRestApiException("Cannot get top menus", e);
     }
   }
+
+  @Override
+  public ExperimentApi experiment(String name) throws RestApiException {
+    try {
+      return experimentApi.create(
+          experimentsCollection.parse(new ConfigResource(), IdString.fromDecoded(name)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse experiment", e);
+    }
+  }
+
+  @Override
+  public ListExperimentsRequest listExperiments() throws RestApiException {
+    return new ListExperimentsRequest() {
+      @Override
+      public ImmutableMap<String, ExperimentInfo> get() throws RestApiException {
+        return ServerImpl.this.listExperiments(this);
+      }
+    };
+  }
+
+  private ImmutableMap<String, ExperimentInfo> listExperiments(ListExperimentsRequest r)
+      throws RestApiException {
+    try {
+      ListExperiments listExperiments = listExperimentsProvider.get();
+      if (r.getEnabledOnly()) {
+        listExperiments.setEnabledOnly(true);
+      }
+      return listExperiments.apply(new ConfigResource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve experiments", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5c24ddc..66914b7 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -86,8 +87,12 @@
 import com.google.gerrit.server.restapi.project.ListSubmitRequirements;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.gerrit.server.restapi.project.PostLabelsReview;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirements;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirementsReview;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
+import com.google.gerrit.server.restapi.project.PutConfigReview;
 import com.google.gerrit.server.restapi.project.PutDescription;
 import com.google.gerrit.server.restapi.project.SetAccess;
 import com.google.gerrit.server.restapi.project.SetHead;
@@ -127,6 +132,7 @@
   private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
+  private final PutConfigReview putConfigReview;
   private final CommitsIncludedInRefs commitsIncludedInRefs;
   private final Provider<ListBranches> listBranches;
   private final Provider<ListTags> listTags;
@@ -147,6 +153,10 @@
   private final Provider<ListLabels> listLabels;
   private final Provider<ListSubmitRequirements> listSubmitRequirements;
   private final PostLabels postLabels;
+  private final PostLabelsReview postLabelsReview;
+
+  private final PostSubmitRequirements postSubmitRequirements;
+  private final PostSubmitRequirementsReview postSubmitRequirementsReview;
   private final LabelApiImpl.Factory labelApi;
   private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
@@ -168,6 +178,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -188,8 +199,11 @@
       Provider<ListLabels> listLabels,
       Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
+      PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -208,6 +222,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        putConfigReview,
         commitsIncludedInRefs,
         listBranches,
         listTags,
@@ -229,8 +244,11 @@
         listLabels,
         listSubmitRequirements,
         postLabels,
+        postLabelsReview,
         labelApi,
         submitRequirementApi,
+        postSubmitRequirements,
+        postSubmitRequirementsReview,
         null);
   }
 
@@ -252,6 +270,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -272,8 +291,11 @@
       Provider<ListLabels> listLabels,
       Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
+      PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -292,6 +314,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        putConfigReview,
         commitsIncludedInRefs,
         listBranches,
         listTags,
@@ -313,8 +336,11 @@
         listLabels,
         listSubmitRequirements,
         postLabels,
+        postLabelsReview,
         labelApi,
         submitRequirementApi,
+        postSubmitRequirements,
+        postSubmitRequirementsReview,
         name);
   }
 
@@ -335,6 +361,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      PutConfigReview putConfigReview,
       CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
@@ -356,8 +383,11 @@
       Provider<ListLabels> listLabels,
       Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
+      PostLabelsReview postLabelsReview,
       LabelApiImpl.Factory labelApi,
       SubmitRequirementApiImpl.Factory submitRequirementApi,
+      PostSubmitRequirements postSubmitRequirements,
+      PostSubmitRequirementsReview postSubmitRequirementsReview,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -375,6 +405,7 @@
     this.setAccess = setAccess;
     this.getConfig = getConfig;
     this.putConfig = putConfig;
+    this.putConfigReview = putConfigReview;
     this.commitsIncludedInRefs = commitsIncludedInRefs;
     this.listBranches = listBranches;
     this.listTags = listTags;
@@ -397,8 +428,11 @@
     this.listLabels = listLabels;
     this.listSubmitRequirements = listSubmitRequirements;
     this.postLabels = postLabels;
+    this.postLabelsReview = postLabelsReview;
     this.labelApi = labelApi;
     this.submitRequirementApi = submitRequirementApi;
+    this.postSubmitRequirements = postSubmitRequirements;
+    this.postSubmitRequirementsReview = postSubmitRequirementsReview;
   }
 
   @Override
@@ -519,6 +553,15 @@
   }
 
   @Override
+  public ChangeInfo configReview(ConfigInput p) throws RestApiException {
+    try {
+      return putConfigReview.apply(checkExists(), p).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put config change", e);
+    }
+  }
+
+  @Override
   public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
       throws RestApiException {
     try {
@@ -814,4 +857,33 @@
       throw asRestApiException("Cannot update labels", e);
     }
   }
+
+  @Override
+  public ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException {
+    try {
+      return postLabelsReview.apply(checkExists(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change for labels update", e);
+    }
+  }
+
+  @Override
+  public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+    try {
+      @SuppressWarnings("unused")
+      var unused = postSubmitRequirements.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirements", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+      throws RestApiException {
+    try {
+      return postSubmitRequirementsReview.apply(checkExists(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change for submit requirements update", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java b/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java
new file mode 100644
index 0000000..9359ca1
--- /dev/null
+++ b/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.args4j;
+
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+
+public class ListTagSortOptionHandler extends OptionHandler<ListTagSortOption> {
+  @Inject
+  public ListTagSortOptionHandler(
+      @Assisted CmdLineParser parser,
+      @Assisted OptionDef option,
+      @Assisted Setter<ListTagSortOption> setter) {
+    super(parser, option, setter);
+  }
+
+  @Override
+  public int parseArguments(Parameters params) throws CmdLineException {
+    String param = params.getParameter(0);
+    try {
+      setter.addValue(ListTagSortOption.valueOf(param.toUpperCase()));
+      return 1;
+    } catch (IllegalArgumentException e) {
+      throw new CmdLineException(
+          owner, localizable("\"%s\" is not a valid sort option: %s"), param, e.getMessage());
+    }
+  }
+
+  @Override
+  public String getDefaultMetaVariable() {
+    return "SORT";
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index fdd55ac..02ae629 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -32,11 +32,13 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.options.BuildBloomFilter;
 import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
-import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -59,6 +61,24 @@
 class H2CacheFactory extends PersistentCacheBaseFactory implements LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  static class PeriodicCachePruner implements Runnable {
+    private final H2CacheImpl<?, ?> cache;
+
+    PeriodicCachePruner(H2CacheImpl<?, ?> cache) {
+      this.cache = cache;
+    }
+
+    @Override
+    public String toString() {
+      return "Disk Cache Pruner (" + cache.getCacheName() + ")";
+    }
+
+    @Override
+    public void run() {
+      cache.prune();
+    }
+  }
+
   private final List<H2CacheImpl<?, ?>> caches;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final ExecutorService executor;
@@ -67,6 +87,8 @@
   private final boolean h2AutoServer;
   private final boolean isOfflineReindex;
   private final boolean buildBloomFilter;
+  private final boolean pruneOnStartup;
+  private final Schedule schedule;
 
   @Inject
   H2CacheFactory(
@@ -74,12 +96,18 @@
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap,
+      WorkQueue queue,
       @Nullable IsFirstInsertForEntry isFirstInsertForEntry,
       @Nullable BuildBloomFilter buildBloomFilter) {
     super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
+    pruneOnStartup = cfg.getBoolean("cachePruning", null, "pruneOnStartup", true);
     caches = new ArrayList<>();
+    schedule =
+        ScheduleConfig.createSchedule(cfg, "cachePruning")
+            .orElseGet(() -> Schedule.createOrFail(Duration.ofDays(1).toMillis(), "01:00"));
+    logger.atInfo().log("Scheduling cache pruning with schedule %s", schedule);
     this.cacheMap = cacheMap;
     this.isOfflineReindex =
         isFirstInsertForEntry != null && isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES);
@@ -92,16 +120,7 @@
               Executors.newFixedThreadPool(
                   1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build()));
 
-      cleanup =
-          isOfflineReindex
-              ? null
-              : new LoggingContextAwareScheduledExecutorService(
-                  Executors.newScheduledThreadPool(
-                      1,
-                      new ThreadFactoryBuilder()
-                          .setNameFormat("DiskCache-Prune-%d")
-                          .setDaemon(true)
-                          .build()));
+      cleanup = isOfflineReindex ? null : queue.createQueue(1, "DiskCache-Prune", true);
     } else {
       executor = null;
       cleanup = null;
@@ -114,9 +133,19 @@
       for (H2CacheImpl<?, ?> cache : caches) {
         executor.execute(cache::start);
         if (cleanup != null) {
+          if (pruneOnStartup) {
+            @SuppressWarnings("unused")
+            Future<?> possiblyIgnoredError =
+                cleanup.schedule(new PeriodicCachePruner(cache), 30, TimeUnit.SECONDS);
+          }
+
           @SuppressWarnings("unused")
           Future<?> possiblyIgnoredError =
-              cleanup.schedule(() -> cache.prune(cleanup), 30, TimeUnit.SECONDS);
+              cleanup.scheduleAtFixedRate(
+                  new PeriodicCachePruner(cache),
+                  schedule.initialDelay(),
+                  schedule.interval(),
+                  TimeUnit.MILLISECONDS);
         }
       }
     }
@@ -231,7 +260,8 @@
         maxSize,
         expireAfterWrite,
         refreshAfterWrite,
-        buildBloomFilter);
+        buildBloomFilter,
+        isOfflineReindex);
   }
 
   private boolean has(String name, String var) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 27a09ed..de61a16 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -47,7 +47,6 @@
 import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Calendar;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -56,9 +55,6 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
@@ -92,6 +88,7 @@
   private final SqlStore<K, V> store;
   private final TypeLiteral<K> keyType;
   private final Cache<K, ValueHolder<V>> mem;
+  private final String cacheName;
 
   H2CacheImpl(
       Executor executor,
@@ -102,6 +99,7 @@
     this.store = store;
     this.keyType = keyType;
     this.mem = mem;
+    this.cacheName = store.url.substring(store.url.lastIndexOf('/') + 1);
   }
 
   @Nullable
@@ -230,20 +228,14 @@
     store.close();
   }
 
-  void prune(ScheduledExecutorService service) {
+  void prune() {
+    logger.atFine().log("Pruning cache %s...", cacheName);
     store.prune(mem);
+    logger.atFine().log("Finished pruning cache %s...", cacheName);
+  }
 
-    Calendar cal = Calendar.getInstance();
-    cal.set(Calendar.HOUR_OF_DAY, 01);
-    cal.set(Calendar.MINUTE, 0);
-    cal.set(Calendar.SECOND, 0);
-    cal.set(Calendar.MILLISECOND, 0);
-    cal.add(Calendar.DAY_OF_MONTH, 1);
-
-    long delay = cal.getTimeInMillis() - TimeUtil.nowMs();
-    @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError =
-        service.schedule(() -> prune(service), delay, TimeUnit.MILLISECONDS);
+  String getCacheName() {
+    return cacheName;
   }
 
   static class ValueHolder<V> {
@@ -367,6 +359,7 @@
     private volatile BloomFilter<K> bloomFilter;
     private int estimatedSize;
     private boolean buildBloomFilter;
+    private boolean isOfflineReindex;
 
     SqlStore(
         String jdbcUrl,
@@ -377,7 +370,8 @@
         long maxSize,
         @Nullable Duration expireAfterWrite,
         @Nullable Duration refreshAfterWrite,
-        boolean buildBloomFilter) {
+        boolean buildBloomFilter,
+        boolean isOfflineReindex) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
@@ -386,6 +380,7 @@
       this.expireAfterWrite = expireAfterWrite;
       this.refreshAfterWrite = refreshAfterWrite;
       this.buildBloomFilter = buildBloomFilter;
+      this.isOfflineReindex = isOfflineReindex;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -431,7 +426,7 @@
     @Nullable
     private BloomFilter<K> buildBloomFilter() {
       SqlHandle c = null;
-      try {
+      try (TraceTimer ignored = TraceContext.newTimer("Build bloom filter", Metadata.empty())) {
         c = acquire();
         if (estimatedSize <= 0) {
           try (PreparedStatement ps =
@@ -509,7 +504,9 @@
           ValueHolder<V> h = new ValueHolder<>(val, created.toInstant());
           h.clean = true;
           hitCount.incrementAndGet();
-          touch(c, key);
+          if (!isOfflineReindex) {
+            touch(c, key);
+          }
           return h;
         } finally {
           c.get.clearParameters();
@@ -773,6 +770,8 @@
             "ALTER TABLE data ADD COLUMN IF NOT EXISTS "
                 + "space BIGINT AS OCTET_LENGTH(k) + OCTET_LENGTH(v)");
         stmt.addBatch("ALTER TABLE data ADD COLUMN IF NOT EXISTS version INT DEFAULT 0 NOT NULL");
+        stmt.addBatch("CREATE INDEX IF NOT EXISTS version_key ON data(version, k)");
+        stmt.addBatch("CREATE INDEX IF NOT EXISTS accessed ON data(accessed)");
         stmt.executeBatch();
       }
     }
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
index dc01567..9663427 100644
--- a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.cancellation;
 
 import com.google.common.base.Throwables;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import java.util.Arrays;
 import java.util.Optional;
 import org.apache.commons.text.WordUtils;
 
@@ -28,7 +30,9 @@
    * {@link RequestCancelledException} is returned. If not, {@link Optional#empty()} is returned.
    */
   public static Optional<RequestCancelledException> getFromCausalChain(Throwable e) {
-    return Throwables.getCausalChain(e).stream()
+    return Streams.concat(
+            Throwables.getCausalChain(e).stream(),
+            Throwables.getCausalChain(e).stream().flatMap(t -> Arrays.stream(t.getSuppressed())))
         .filter(RequestCancelledException.class::isInstance)
         .map(RequestCancelledException.class::cast)
         .findFirst();
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 07280ba..1a2c63d 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
@@ -40,7 +39,6 @@
 public class AbandonUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
   private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
   private final BatchAbandon batchAbandon;
@@ -48,27 +46,27 @@
 
   @Inject
   AbandonUtil(
-      ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
       Provider<ChangeQueryBuilder> queryBuilderProvider,
       BatchAbandon batchAbandon) {
-    this.cfg = cfg;
     this.queryProvider = queryProvider;
     this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
 
-  public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
-    if (cfg.getAbandonAfter() <= 0) {
+  public void abandonInactiveOpenChanges(
+      BatchUpdate.Factory updateFactory,
+      long abandonAfterMillis,
+      boolean abandonIfMergeable,
+      String message) {
+    if (abandonAfterMillis <= 0) {
       return;
     }
-
     try {
-      String query =
-          "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter()) + "m";
-      if (!cfg.getAbandonIfMergeable()) {
+      String query = "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(abandonAfterMillis) + "m";
+      if (!abandonIfMergeable) {
         query += " -is:mergeable";
       }
 
@@ -86,7 +84,6 @@
 
       int count = 0;
       ImmutableListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
-      String message = cfg.getAbandonMessage();
       for (Project.NameKey project : abandons.keySet()) {
         List<ChangeData> changes = getValidChanges(abandons.get(project), query);
         try {
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 52230ba..950d390 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -31,6 +31,9 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -179,38 +182,43 @@
 
   private Map<String, ActionInfo> toActionMap(
       ChangeData changeData, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
-    CurrentUser user = userProvider.get();
-    Map<String, ActionInfo> out = new LinkedHashMap<>();
-    if (!user.isIdentifiedUser()) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get actions",
+            Metadata.builder().changeId(changeData.change().getId().get()).build())) {
+      CurrentUser user = userProvider.get();
+      Map<String, ActionInfo> out = new LinkedHashMap<>();
+      if (!user.isIdentifiedUser()) {
+        return out;
+      }
+
+      Iterable<UiAction.Description> descs =
+          uiActions.from(changeViews, changeResourceFactory.create(changeData, user));
+
+      // The followup action is a client-side only operation that does not
+      // have a server side handler. It must be manually registered into the
+      // resulting action map.
+      if (!changeData.change().isAbandoned()) {
+        UiAction.Description descr = new UiAction.Description();
+        PrivateInternals_UiActionDescription.setId(descr, "followup");
+        PrivateInternals_UiActionDescription.setMethod(descr, "POST");
+        descr.setTitle("Create follow-up change");
+        descr.setLabel("Follow-Up");
+        descs = Iterables.concat(descs, Collections.singleton(descr));
+      }
+
+      ACTION:
+      for (UiAction.Description d : descs) {
+        ActionInfo actionInfo = new ActionInfo(d);
+        for (ActionVisitor visitor : visitors) {
+          if (!visitor.visit(d.getId(), actionInfo, changeInfo)) {
+            continue ACTION;
+          }
+        }
+        out.put(d.getId(), actionInfo);
+      }
       return out;
     }
-
-    Iterable<UiAction.Description> descs =
-        uiActions.from(changeViews, changeResourceFactory.create(changeData, user));
-
-    // The followup action is a client-side only operation that does not
-    // have a server side handler. It must be manually registered into the
-    // resulting action map.
-    if (!changeData.change().isAbandoned()) {
-      UiAction.Description descr = new UiAction.Description();
-      PrivateInternals_UiActionDescription.setId(descr, "followup");
-      PrivateInternals_UiActionDescription.setMethod(descr, "POST");
-      descr.setTitle("Create follow-up change");
-      descr.setLabel("Follow-Up");
-      descs = Iterables.concat(descs, Collections.singleton(descr));
-    }
-
-    ACTION:
-    for (UiAction.Description d : descs) {
-      ActionInfo actionInfo = new ActionInfo(d);
-      for (ActionVisitor visitor : visitors) {
-        if (!visitor.visit(d.getId(), actionInfo, changeInfo)) {
-          continue ACTION;
-        }
-      }
-      out.put(d.getId(), actionInfo);
-    }
-    return out;
   }
 
   private ImmutableMap<String, ActionInfo> toActionMap(
diff --git a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
index 0ed1f11..b5ac87f5 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
@@ -63,8 +63,8 @@
     return format.suffixes();
   }
 
-  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
-    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
+  public ArchiveOutputStream<?> createArchiveOutputStream(OutputStream o) throws IOException {
+    return (ArchiveOutputStream<?>) this.format.createArchiveOutputStream(o);
   }
 
   public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 2f2cff9..728830c 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
@@ -25,6 +26,8 @@
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 /** Runnable to enable scheduling change cleanups to run periodically */
 public class ChangeCleanupRunner implements Runnable {
@@ -34,18 +37,25 @@
     @Override
     protected void configure() {
       listener().to(Lifecycle.class);
+      factory(Factory.class);
     }
   }
 
+  public interface Factory {
+    ChangeCleanupRunner create();
+
+    ChangeCleanupRunner create(long abandonAfterMillis, boolean abandonIfMergeable, String message);
+  }
+
   static class Lifecycle implements LifecycleListener {
     private final WorkQueue queue;
     private final ChangeCleanupRunner runner;
     private final ChangeCleanupConfig cfg;
 
     @Inject
-    Lifecycle(WorkQueue queue, ChangeCleanupRunner runner, ChangeCleanupConfig cfg) {
+    Lifecycle(WorkQueue queue, ChangeCleanupRunner.Factory runner, ChangeCleanupConfig cfg) {
       this.queue = queue;
-      this.runner = runner;
+      this.runner = runner.create();
       this.cfg = cfg;
     }
 
@@ -63,13 +73,38 @@
   private final OneOffRequestContext oneOffRequestContext;
   private final AbandonUtil abandonUtil;
   private final RetryHelper retryHelper;
+  private final long abandonAfterMillis;
+  private final boolean abandonIfMergeable;
+  @Nullable private final String message;
 
-  @Inject
+  @AssistedInject
   ChangeCleanupRunner(
-      OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper) {
+      OneOffRequestContext oneOffRequestContext,
+      AbandonUtil abandonUtil,
+      RetryHelper retryHelper,
+      @Assisted long abandonAfterMillis,
+      @Assisted boolean abandonIfMergeable,
+      @Assisted @Nullable String message) {
     this.oneOffRequestContext = oneOffRequestContext;
     this.abandonUtil = abandonUtil;
     this.retryHelper = retryHelper;
+    this.abandonAfterMillis = abandonAfterMillis;
+    this.abandonIfMergeable = abandonIfMergeable;
+    this.message = message;
+  }
+
+  @AssistedInject
+  ChangeCleanupRunner(
+      OneOffRequestContext oneOffRequestContext,
+      AbandonUtil abandonUtil,
+      RetryHelper retryHelper,
+      ChangeCleanupConfig cfg) {
+    this.oneOffRequestContext = oneOffRequestContext;
+    this.abandonUtil = abandonUtil;
+    this.retryHelper = retryHelper;
+    this.abandonAfterMillis = cfg.getAbandonAfter();
+    this.abandonIfMergeable = cfg.getAbandonIfMergeable();
+    this.message = cfg.getAbandonMessage();
   }
 
   @Override
@@ -85,7 +120,8 @@
               .changeUpdate(
                   "abandonInactiveOpenChanges",
                   updateFactory -> {
-                    abandonUtil.abandonInactiveOpenChanges(updateFactory);
+                    abandonUtil.abandonInactiveOpenChanges(
+                        updateFactory, abandonAfterMillis, abandonIfMergeable, message);
                     return null;
                   })
               .call();
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
deleted file mode 100644
index 2fd5755..0000000
--- a/java/com/google/gerrit/server/change/ChangeETagComputation.java
+++ /dev/null
@@ -1,63 +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.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
-/**
- * Allows plugins to contribute a value to the change ETag computation.
- *
- * <p>Plugins can affect the result of the get change / get change details REST endpoints by:
- *
- * <ul>
- *   <li>providing plugin defined attributes to {@link
- *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
- *       ChangePluginDefinedInfoFactory})
- *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
- *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
- * </ul>
- *
- * <p>If the plugin defined part of {@link com.google.gerrit.extensions.common.ChangeInfo} depends
- * on plugin specific data, callers that use the change ETags to avoid unneeded recomputations of
- * ChangeInfos may see outdated plugin attributes and/or outdated submittable information, because a
- * ChangeInfo is only reloaded if the change ETag changes.
- *
- * <p>By implementating this interface plugins can contribute to the change ETag computation and
- * thus ensure that the ETag changes when the plugin data was changed. This way it is ensured that
- * callers do not see outdated ChangeInfos.
- *
- * @see ChangeResource#getETag()
- */
-@ExtensionPoint
-public interface ChangeETagComputation {
-  /**
-   * Computes an ETag of plugin-specific data for the given change.
-   *
-   * <p><strong>Note:</strong> Change ETags are computed very frequently and the computation must be
-   * cheap. Take good care to not perform any expensive computations when implementing this.
-   *
-   * <p>If an error is encountered during the ETag computation the plugin can indicate this by
-   * throwing any RuntimeException. In this case no value will be included in the change ETag
-   * computation. This means if the error is transient, the ETag will differ when the computation
-   * succeeds on a follow-up run.
-   *
-   * @param projectName the name of the project that contains the change
-   * @param changeId ID of the change for which the ETag should be computed
-   * @return the ETag
-   */
-  String getETag(Project.NameKey projectName, Change.Id changeId);
-}
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index 9f253de..5668c27 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -208,6 +208,12 @@
     return Optional.of(notes.get(0));
   }
 
+  /**
+   * @deprecated this method is not reliable in Gerrit instances with imported changes, since
+   *     multiple changes can have the same change number and make the `changeIdProjectCache` cache
+   *     pointless.
+   */
+  @Deprecated(since = "3.10", forRemoval = true)
   public List<ChangeNotes> find(Change.Id id) {
     String project = changeIdProjectCache.getIfPresent(id);
     if (project != null) {
@@ -245,7 +251,7 @@
     // this case.)
     Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
     for (ChangeData cd : cds) {
-      if (seen.add(cd.getId())) {
+      if (seen.add(cd.virtualId())) {
         try {
           notes.add(cd.notes());
         } catch (NoSuchChangeException e) {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 74f8ccc..46f3e0e 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -63,7 +63,6 @@
 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.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
@@ -86,7 +85,6 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -107,12 +105,17 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -376,33 +379,47 @@
   }
 
   private static List<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
-    List<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
-    for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
-      if (submitRecord.requirements == null) {
-        continue;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get requirements", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
+      for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
+        if (submitRecord.requirements == null) {
+          continue;
+        }
+        for (LegacySubmitRequirement requirement : submitRecord.requirements) {
+          reqInfos.add(requirementToInfo(requirement, submitRecord.status));
+        }
       }
-      for (LegacySubmitRequirement requirement : submitRecord.requirements) {
-        reqInfos.add(requirementToInfo(requirement, submitRecord.status));
-      }
+      return reqInfos;
     }
-    return reqInfos;
   }
 
   private List<SubmitRecordInfo> submitRecordsFor(ChangeData cd) {
-    List<SubmitRecordInfo> submitRecordInfos = new ArrayList<>();
-    for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
-      submitRecordInfos.add(submitRecordToInfo(record));
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get submit records", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<SubmitRecordInfo> submitRecordInfos = new ArrayList<>();
+      for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
+        submitRecordInfos.add(submitRecordToInfo(record));
+      }
+      return submitRecordInfos;
     }
-    return submitRecordInfos;
   }
 
   private List<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
-    List<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
-    cd.submitRequirementsIncludingLegacy().entrySet().stream()
-        .filter(entry -> !entry.getValue().isHidden())
-        .forEach(
-            entry -> reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
-    return reqInfos;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get submit requirements",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
+      cd.submitRequirementsIncludingLegacy().entrySet().stream()
+          .filter(entry -> !entry.getValue().isHidden())
+          .forEach(
+              entry ->
+                  reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
+      return reqInfos;
+    }
   }
 
   private static LegacySubmitRequirementInfo requirementToInfo(
@@ -477,22 +494,30 @@
     }
   }
 
-  private void ensureLoaded(Iterable<ChangeData> all) {
+  private void ensureLoaded(Collection<ChangeData> all) {
     if (lazyLoad) {
-      for (ChangeData cd : all) {
-        // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
-        cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Load change data for lazyLoad options",
+              Metadata.builder().resourceCount(all.size()).build())) {
+        for (ChangeData cd : all) {
+          // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
+          cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+        }
+        ChangeData.ensureChangeLoaded(all);
+        if (has(ALL_REVISIONS)) {
+          ChangeData.ensureAllPatchSetsLoaded(all);
+        } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
+          ChangeData.ensureCurrentPatchSetLoaded(all);
+        }
+        if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
+          ChangeData.ensureReviewedByLoadedForOpenChanges(all);
+        }
+        if (has(STAR) && userProvider.get().isIdentifiedUser()) {
+          ChangeData.ensureChangeServerId(all);
+        }
+        ChangeData.ensureCurrentApprovalsLoaded(all);
       }
-      ChangeData.ensureChangeLoaded(all);
-      if (has(ALL_REVISIONS)) {
-        ChangeData.ensureAllPatchSetsLoaded(all);
-      } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
-        ChangeData.ensureCurrentPatchSetLoaded(all);
-      }
-      if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
-        ChangeData.ensureReviewedByLoadedForOpenChanges(all);
-      }
-      ChangeData.ensureCurrentApprovalsLoaded(all);
     } else {
       for (ChangeData cd : all) {
         // Mark all ChangeDatas as coming from the index. Disallow using NoteDb
@@ -527,7 +552,8 @@
           }
           continue;
         }
-        ChangeInfo info = cache.get(cd.getId());
+        Change.Id cdUniqueId = cd.virtualId();
+        ChangeInfo info = cache.get(cdUniqueId);
         if (info != null && isCacheable) {
           changeInfos.add(info);
           continue;
@@ -539,7 +565,7 @@
           info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
           changeInfos.add(info);
           if (isCacheable) {
-            cache.put(Change.id(info._number), info);
+            cache.put(cdUniqueId, info);
           }
         } catch (RuntimeException e) {
           Optional<RequestCancelledException> requestCancelledException =
@@ -618,10 +644,12 @@
     if (has(CHECK)) {
       out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
       // If any problems were fixed, the ChangeData needs to be reloaded.
-      for (ProblemInfo p : out.problems) {
-        if (p.status == ProblemInfo.Status.FIXED) {
+      if (out.problems.stream().anyMatch(p -> p.status == ProblemInfo.Status.FIXED)) {
+        try (TraceTimer timer =
+            TraceContext.newTimer(
+                "Reload change data after fixing a problem",
+                Metadata.builder().changeId(cd.change().getChangeId()).build())) {
           cd = changeDataFactory.create(cd.project(), cd.getId());
-          break;
         }
       }
     }
@@ -629,6 +657,7 @@
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().shortName();
+    out.currentRevisionNumber = in.currentPatchSetId().get();
     out.topic = in.getTopic();
     if (!cd.attentionSet().isEmpty()) {
       out.removedFromAttentionSet =
@@ -678,28 +707,18 @@
     out.setCreated(in.getCreatedOn());
     out.setUpdated(in.getLastUpdatedOn());
     out._number = in.getId().get();
-    out.totalCommentCount = cd.totalCommentCount();
-    out.unresolvedCommentCount = cd.unresolvedCommentCount();
 
-    if (cd.getRefStates() != null) {
-      String metaName = RefNames.changeMetaRef(cd.getId());
-      Optional<RefState> metaState =
-          cd.getRefStates().values().stream().filter(r -> r.ref().equals(metaName)).findAny();
-
-      // metaState should always be there, but it doesn't hurt to be extra careful.
-      metaState.ifPresent(rs -> out.metaRevId = rs.id().getName());
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Count comments", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      out.totalCommentCount = cd.totalCommentCount();
+      out.unresolvedCommentCount = cd.unresolvedCommentCount();
     }
 
-    if (user.isIdentifiedUser()) {
-      if (cd.isStarred(user.getAccountId())) {
-        out.starred = true;
-      }
-    }
+    getMetaState(cd).ifPresent(id -> out.metaRevId = id.getName());
 
-    if (in.isNew() && has(REVIEWED) && user.isIdentifiedUser()) {
-      out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
-    }
-
+    out.reviewed = isReviewedByCurrentUser(cd, user);
+    out.starred = isStarredByCurrentUser(cd, user);
     out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS));
     out.requirements = requirementsFor(cd);
     out.submitRecords = submitRecordsFor(cd);
@@ -776,164 +795,239 @@
     }
 
     if (has(TRACKING_IDS)) {
-      ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
-      out.trackingIds =
-          set.entries().stream()
-              .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
-              .collect(toList());
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Get tracking IDs", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+        ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
+        out.trackingIds =
+            set.entries().stream()
+                .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
+                .collect(toList());
+      }
     }
 
+    out.virtualIdNumber = cd.virtualId().get();
+
     return out;
   }
 
   private Map<ReviewerState, Collection<AccountInfo>> reviewerMap(
       ReviewerSet reviewers, ReviewerByEmailSet reviewersByEmail, boolean includeRemoved) {
-    Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
-    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
-      if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
-        continue;
+    try (TraceTimer timer = TraceContext.newTimer("Get reviewer map", Metadata.empty())) {
+      Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
+      for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+        if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
+          continue;
+        }
+        List<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
+        reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
+        if (!reviewersByState.isEmpty()) {
+          reviewerMap.put(state.asReviewerState(), reviewersByState);
+        }
       }
-      List<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
-      reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
-      if (!reviewersByState.isEmpty()) {
-        reviewerMap.put(state.asReviewerState(), reviewersByState);
-      }
+      return reviewerMap;
     }
-    return reviewerMap;
   }
 
   private List<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) {
-    List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
-    List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
-    for (ReviewerStatusUpdate c : reviewerUpdates) {
-      if (c.reviewer().isPresent()) {
-        result.add(
-            new ReviewerUpdateInfo(
-                c.date(),
-                accountLoader.get(c.updatedBy()),
-                accountLoader.get(c.reviewer().get()),
-                c.state().asReviewerState()));
-      }
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get reviewer updates",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
+      List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
+      for (ReviewerStatusUpdate c : reviewerUpdates) {
+        if (c.reviewer().isPresent()) {
+          result.add(
+              new ReviewerUpdateInfo(
+                  c.date(),
+                  accountLoader.get(c.updatedBy()),
+                  accountLoader.get(c.reviewer().get()),
+                  c.state().asReviewerState()));
+        }
 
-      if (c.reviewerByEmail().isPresent()) {
-        result.add(
-            new ReviewerUpdateInfo(
-                c.date(),
-                accountLoader.get(c.updatedBy()),
-                toAccountInfoByEmail(c.reviewerByEmail().get()),
-                c.state().asReviewerState()));
+        if (c.reviewerByEmail().isPresent()) {
+          result.add(
+              new ReviewerUpdateInfo(
+                  c.date(),
+                  accountLoader.get(c.updatedBy()),
+                  toAccountInfoByEmail(c.reviewerByEmail().get()),
+                  c.state().asReviewerState()));
+        }
       }
+      return result;
     }
-    return result;
   }
 
   private boolean submittable(ChangeData cd) {
-    return cd.submitRequirementsIncludingLegacy().values().stream()
-        .allMatch(SubmitRequirementResult::fulfilled);
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Compute submittability",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return cd.submitRequirementsIncludingLegacy().values().stream()
+          .allMatch(SubmitRequirementResult::fulfilled);
+    }
+  }
+
+  private Optional<ObjectId> getMetaState(ChangeData cd) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get change meta ref",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return cd.metaRevision();
+    }
+  }
+
+  private Boolean isReviewedByCurrentUser(ChangeData cd, CurrentUser user) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get reviewed by", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return toBoolean(
+          cd.change().isNew()
+              && has(REVIEWED)
+              && user.isIdentifiedUser()
+              && cd.isReviewedBy(user.getAccountId()));
+    }
+  }
+
+  private Boolean isStarredByCurrentUser(ChangeData cd, CurrentUser user) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get starred by", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return toBoolean(user.isIdentifiedUser() && cd.isStarred(user.getAccountId()));
+    }
   }
 
   private void setSubmitter(ChangeData cd, ChangeInfo out) {
-    Optional<PatchSetApproval> s = cd.getSubmitApproval();
-    if (!s.isPresent()) {
-      return;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Set submitter", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      Optional<PatchSetApproval> s = cd.getSubmitApproval();
+      if (!s.isPresent()) {
+        return;
+      }
+      out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
     }
-    out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
   }
 
   private ImmutableList<ChangeMessageInfo> messages(ChangeData cd) {
-    List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
-    if (messages.isEmpty()) {
-      return ImmutableList.of();
-    }
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get messages", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
+      if (messages.isEmpty()) {
+        return ImmutableList.of();
+      }
 
-    List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
-    for (ChangeMessage message : messages) {
-      result.add(createChangeMessageInfo(message, accountLoader));
+      List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
+      for (ChangeMessage message : messages) {
+        result.add(createChangeMessageInfo(message, accountLoader));
+      }
+      return ImmutableList.copyOf(result);
     }
-    return ImmutableList.copyOf(result);
   }
 
   private List<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
       throws PermissionBackendException {
-    // Although this is called removableReviewers, this method also determines
-    // which CCs are removable.
-    //
-    // For reviewers, we need to look at each approval, because the reviewer
-    // should only be considered removable if *all* of their approvals can be
-    // removed. First, add all reviewers with *any* removable approval to the
-    // "removable" set. Along the way, if we encounter a non-removable approval,
-    // add the reviewer to the "fixed" set. Before we return, remove all members
-    // of "fixed" from "removable", because not all of their approvals can be
-    // removed.
-    Collection<LabelInfo> labels = out.labels.values();
-    Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
-    Set<Account.Id> removable = new HashSet<>();
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get removable reviewers",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      // Although this is called removableReviewers, this method also determines
+      // which CCs are removable.
+      //
+      // For reviewers, we need to look at each approval, because the reviewer
+      // should only be considered removable if *all* of their approvals can be
+      // removed. First, add all reviewers with *any* removable approval to the
+      // "removable" set. Along the way, if we encounter a non-removable approval,
+      // add the reviewer to the "fixed" set. Before we return, remove all members
+      // of "fixed" from "removable", because not all of their approvals can be
+      // removed.
+      Collection<LabelInfo> labels = out.labels.values();
+      Set<Account.Id> fixed = new HashSet<>();
 
-    // Add all reviewers, which will later be removed if they are in the "fixed" set.
-    removable.addAll(
-        out.reviewers.getOrDefault(ReviewerState.REVIEWER, Collections.emptySet()).stream()
-            .filter(a -> a._accountId != null)
-            .map(a -> Account.id(a._accountId))
-            .collect(Collectors.toSet()));
+      // the submitter cannot be removed since the submission is recorded by a SUBM approval which
+      // must not be removed by removing the submitter
+      cd.getSubmitApproval().ifPresent(submitApproval -> fixed.add(submitApproval.accountId()));
 
-    // Check if the user has the permission to remove a reviewer. This means we can bypass the
-    // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
-    // permission checks.
-    boolean canRemoveAnyReviewer =
-        permissionBackend
-            .user(userProvider.get())
-            .change(cd)
-            .test(ChangePermission.REMOVE_REVIEWER);
-    for (LabelInfo label : labels) {
-      if (label.all == null) {
-        continue;
-      }
-      for (ApprovalInfo ai : label.all) {
-        Account.Id id = Account.id(ai._accountId);
+      Set<Account.Id> removable = new HashSet<>();
 
-        if (!canRemoveAnyReviewer
-            && !removeReviewerControl.testRemoveReviewer(
-                cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
-          fixed.add(id);
+      // Add all reviewers, which will later be removed if they are in the "fixed" set.
+      removable.addAll(
+          out.reviewers.getOrDefault(ReviewerState.REVIEWER, Collections.emptySet()).stream()
+              .filter(a -> a._accountId != null)
+              .map(a -> Account.id(a._accountId))
+              .collect(Collectors.toSet()));
+
+      // Check if the user has the permission to remove a reviewer. This means we can bypass the
+      // permission checks for a specific reviewer in the loop saving potentially many permission
+      // checks.
+      PermissionBackend.WithUser withUser = permissionBackend.user(userProvider.get());
+      boolean canRemoveAnyReviewer =
+          withUser.change(cd).test(ChangePermission.REMOVE_REVIEWER)
+              || withUser
+                  .project(cd.project())
+                  .ref(cd.change().getDest().branch())
+                  .test(RefPermission.WRITE_CONFIG)
+              || withUser.test(GlobalPermission.ADMINISTRATE_SERVER);
+
+      for (LabelInfo label : labels) {
+        if (label.all == null) {
+          continue;
         }
-      }
-    }
-
-    // CCs are simpler than reviewers. They are removable if the ChangeControl
-    // would permit a non-negative approval by that account to be removed, in
-    // which case add them to removable. We don't need to add unremovable CCs to
-    // "fixed" because we only visit each CC once here.
-    Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
-    if (ccs != null) {
-      for (AccountInfo ai : ccs) {
-        if (ai._accountId != null) {
+        for (ApprovalInfo ai : label.all) {
           Account.Id id = Account.id(ai._accountId);
-          if (canRemoveAnyReviewer
-              || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
-            removable.add(id);
+          if (fixed.contains(id)) {
+            // we already found that this reviewer cannot be removed, no need to check again
+            continue;
+          }
+
+          int value = MoreObjects.firstNonNull(ai.value, 0);
+          if ((cd.change().isMerged() && value != 0)
+              || (!canRemoveAnyReviewer
+                  && !RemoveReviewerControl.canRemoveReviewerWithoutPermissionCheck(
+                      cd.change(), userProvider.get(), id, value))) {
+            fixed.add(id);
           }
         }
       }
-    }
 
-    // Subtract any reviewers with non-removable approvals from the "removable"
-    // set. This also subtracts any CCs that for some reason also hold
-    // unremovable approvals.
-    removable.removeAll(fixed);
-
-    List<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
-    for (Account.Id id : removable) {
-      result.add(accountLoader.get(id));
-    }
-    // Reviewers added by email are always removable
-    for (Collection<AccountInfo> infos : out.reviewers.values()) {
-      for (AccountInfo info : infos) {
-        if (info._accountId == null) {
-          result.add(info);
+      // CCs are simpler than reviewers. They are removable if the ChangeControl
+      // would permit a non-negative approval by that account to be removed, in
+      // which case add them to removable. We don't need to add unremovable CCs to
+      // "fixed" because we only visit each CC once here.
+      Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
+      if (ccs != null) {
+        for (AccountInfo ai : ccs) {
+          if (ai._accountId != null) {
+            Account.Id id = Account.id(ai._accountId);
+            if (canRemoveAnyReviewer
+                || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+              removable.add(id);
+            }
+          }
         }
       }
+
+      // Subtract any reviewers with non-removable approvals from the "removable"
+      // set. This also subtracts any CCs that for some reason also hold
+      // unremovable approvals.
+      removable.removeAll(fixed);
+
+      List<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
+      for (Account.Id id : removable) {
+        result.add(accountLoader.get(id));
+      }
+      // Reviewers added by email are always removable
+      for (Collection<AccountInfo> infos : out.reviewers.values()) {
+        for (AccountInfo info : infos) {
+          if (info._accountId == null) {
+            result.add(info);
+          }
+        }
+      }
+      return result;
     }
-    return result;
   }
 
   private List<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
@@ -956,30 +1050,34 @@
 
   private ImmutableMap<PatchSet.Id, PatchSet> loadPatchSets(
       ChangeData cd, Optional<PatchSet.Id> limitToPsId) {
-    Collection<PatchSet> src;
-    if (has(ALL_REVISIONS) || has(MESSAGES)) {
-      src = cd.patchSets();
-    } else {
-      PatchSet ps;
-      if (limitToPsId.isPresent()) {
-        ps = cd.patchSet(limitToPsId.get());
-        if (ps == null) {
-          throw new StorageException("missing patch set " + limitToPsId.get());
-        }
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Load patch sets", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      Collection<PatchSet> src;
+      if (has(ALL_REVISIONS) || has(MESSAGES)) {
+        src = cd.patchSets();
       } else {
-        ps = cd.currentPatchSet();
-        if (ps == null) {
-          throw new StorageException("missing current patch set for change " + cd.getId());
+        PatchSet ps;
+        if (limitToPsId.isPresent()) {
+          ps = cd.patchSet(limitToPsId.get());
+          if (ps == null) {
+            throw new StorageException("missing patch set " + limitToPsId.get());
+          }
+        } else {
+          ps = cd.currentPatchSet();
+          if (ps == null) {
+            throw new StorageException("missing current patch set for change " + cd.getId());
+          }
         }
+        src = Collections.singletonList(ps);
       }
-      src = Collections.singletonList(ps);
+      // Sort by patch set ID in increasing order to have a stable output.
+      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
+      for (PatchSet patchSet : src) {
+        map.put(patchSet.id(), patchSet);
+      }
+      return map.build();
     }
-    // Sort by patch set ID in increasing order to have a stable output.
-    ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
-    for (PatchSet patchSet : src) {
-      map.put(patchSet.id(), patchSet);
-    }
-    return map.build();
   }
 
   /** Populate the 'starred' field. */
@@ -988,14 +1086,15 @@
     // repository only once
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       List<Change.Id> changeIds =
-          changeInfos.stream().map(c -> Change.id(c._number)).collect(Collectors.toList());
+          changeInfos.stream().map(c -> Change.id(c.virtualIdNumber)).collect(Collectors.toList());
       Set<Change.Id> starredChanges =
           starredChangesreader.areStarred(
               allUsersRepo, changeIds, userProvider.get().asIdentifiedUser().getAccountId());
       if (starredChanges.isEmpty()) {
         return;
       }
-      changeInfos.stream().forEach(c -> c.starred = starredChanges.contains(Change.id(c._number)));
+      changeInfos.stream()
+          .forEach(c -> c.starred = starredChanges.contains(Change.id(c.virtualIdNumber)));
     } catch (IOException e) {
       logger.atWarning().withCause(e).log("Failed to open All-Users repo.");
     }
@@ -1008,7 +1107,11 @@
   private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
       Collection<ChangeData> cds) {
     if (pluginDefinedInfosFactory.isPresent()) {
-      return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Get plugin infos", Metadata.builder().resourceCount(cds.size()).build())) {
+        return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+      }
     }
     return ImmutableListMultimap.of();
   }
@@ -1033,4 +1136,9 @@
     info.owner = new AccountInfo(c.getOwner().get());
     return Optional.of(info);
   }
+
+  @Nullable
+  private static Boolean toBoolean(boolean value) {
+    return value ? true : null;
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 74aa373..90752c0 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -193,7 +193,7 @@
     @Override
     public ChangeKind call() throws IOException {
       if (Objects.equals(key.prior(), key.next())) {
-        return ChangeKind.NO_CODE_CHANGE;
+        return ChangeKind.NO_CHANGE;
       }
 
       RevWalk rw = alreadyOpenRw;
@@ -210,15 +210,10 @@
         RevCommit next = rw.parseCommit(key.next());
         rw.parseBody(next);
 
-        if (!next.getFullMessage().equals(prior.getFullMessage())) {
-          if (isSameDeltaAndTree(rw, prior, next)) {
-            return ChangeKind.NO_CODE_CHANGE;
-          }
-          return ChangeKind.REWORK;
-        }
+        boolean commitMessageChanged = !next.getFullMessage().equals(prior.getFullMessage());
 
         if (isSameDeltaAndTree(rw, prior, next)) {
-          return ChangeKind.NO_CHANGE;
+          return commitMessageChanged ? ChangeKind.NO_CODE_CHANGE : ChangeKind.NO_CHANGE;
         }
 
         if (prior.getParentCount() == 0 || next.getParentCount() == 0) {
@@ -243,9 +238,11 @@
           if (merger.merge(next.getParent(0), prior)
               && merger.getResultTreeId().equals(next.getTree())) {
             if (prior.getParentCount() == 1) {
-              return ChangeKind.TRIVIAL_REBASE;
+              return commitMessageChanged
+                  ? ChangeKind.TRIVIAL_REBASE_WITH_MESSAGE_UPDATE
+                  : ChangeKind.TRIVIAL_REBASE;
             }
-            return ChangeKind.MERGE_FIRST_PARENT_UPDATE;
+            return commitMessageChanged ? ChangeKind.REWORK : ChangeKind.MERGE_FIRST_PARENT_UPDATE;
           }
         } catch (LargeObjectException e) {
           // Some object is too large for the merge attempt to succeed. Assume
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 481c17b..a7fa6f4 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -14,54 +14,20 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesReader;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.util.HashSet;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
 
-public class ChangeResource implements RestResource, HasETag {
-  /**
-   * JSON format version number for ETag computations.
-   *
-   * <p>Should be bumped on any JSON format change (new fields, etc.) so that otherwise unmodified
-   * changes get new ETags.
-   */
-  public static final int JSON_FORMAT_VERSION = 1;
-
+public class ChangeResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND = new TypeLiteral<>() {};
 
   public interface Factory {
@@ -70,59 +36,27 @@
     ChangeResource create(ChangeData changeData, CurrentUser user);
   }
 
-  private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
-
-  private final AccountCache accountCache;
-  private final ApprovalsUtil approvalUtil;
-  private final PatchSetUtil patchSetUtil;
   private final PermissionBackend permissionBackend;
-  private final StarredChangesReader starredChangesReader;
-  private final ProjectCache projectCache;
-  private final PluginSetContext<ChangeETagComputation> changeETagComputation;
   private final ChangeData changeData;
   private final CurrentUser user;
 
   @AssistedInject
   ChangeResource(
-      AccountCache accountCache,
-      ApprovalsUtil approvalUtil,
-      PatchSetUtil patchSetUtil,
       PermissionBackend permissionBackend,
-      StarredChangesReader starredChangesReader,
-      ProjectCache projectCache,
-      PluginSetContext<ChangeETagComputation> changeETagComputation,
       ChangeData.Factory changeDataFactory,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
-    this.accountCache = accountCache;
-    this.approvalUtil = approvalUtil;
-    this.patchSetUtil = patchSetUtil;
     this.permissionBackend = permissionBackend;
-    this.starredChangesReader = starredChangesReader;
-    this.projectCache = projectCache;
-    this.changeETagComputation = changeETagComputation;
     this.changeData = changeDataFactory.create(notes);
     this.user = user;
   }
 
   @AssistedInject
   ChangeResource(
-      AccountCache accountCache,
-      ApprovalsUtil approvalUtil,
-      PatchSetUtil patchSetUtil,
       PermissionBackend permissionBackend,
-      StarredChangesReader starredChangesReader,
-      ProjectCache projectCache,
-      PluginSetContext<ChangeETagComputation> changeETagComputation,
       @Assisted ChangeData changeData,
       @Assisted CurrentUser user) {
-    this.accountCache = accountCache;
-    this.approvalUtil = approvalUtil;
-    this.patchSetUtil = patchSetUtil;
     this.permissionBackend = permissionBackend;
-    this.starredChangesReader = starredChangesReader;
-    this.projectCache = projectCache;
-    this.changeETagComputation = changeETagComputation;
     this.changeData = changeData;
     this.user = user;
   }
@@ -161,97 +95,7 @@
     return changeData;
   }
 
-  // This includes all information relevant for ETag computation
-  // unrelated to the UI.
-  public void prepareETag(Hasher h, CurrentUser user) {
-    h.putInt(JSON_FORMAT_VERSION)
-        .putLong(getChange().getLastUpdatedOn().toEpochMilli())
-        .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
-
-    if (user.isIdentifiedUser()) {
-      for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
-        h.putBytes(uuid.get().getBytes(UTF_8));
-      }
-    }
-
-    byte[] buf = new byte[20];
-    Set<Account.Id> accounts = new HashSet<>();
-    accounts.add(getChange().getOwner());
-    try {
-      patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
-
-      // It's intentional to include the states for *all* reviewers into the ETag computation.
-      // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
-      // Including removed reviewers is a cheap way of making sure that the states of accounts that
-      // posted a message on the change are included. Loading all change messages to find the exact
-      // set of accounts that posted a message is too expensive. However everyone who posts a
-      // message is automatically added as reviewer. Hence if we include removed reviewers we can
-      // be sure that we have all accounts that posted messages on the change.
-      accounts.addAll(approvalUtil.getReviewers(getNotes()).all());
-    } catch (StorageException e) {
-      // This ETag will be invalidated if it loads next time.
-    }
-
-    for (Account.Id accountId : accounts) {
-      Optional<AccountState> accountState = accountCache.get(accountId);
-      if (accountState.isPresent()) {
-        hashAccount(h, accountState.get(), buf);
-      } else {
-        h.putInt(accountId.get());
-      }
-    }
-
-    ObjectId noteId;
-    try {
-      noteId = getNotes().loadRevision();
-    } catch (StorageException e) {
-      noteId = null; // This ETag will be invalidated if it loads next time.
-    }
-    hashObjectId(h, noteId, buf);
-    // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
-    // and edits.
-
-    Iterable<ProjectState> projectStateTree =
-        projectCache.get(getProject()).orElseThrow(illegalState(getProject())).tree();
-    for (ProjectState p : projectStateTree) {
-      hashObjectId(h, p.getConfig().getRevision().orElse(null), buf);
-    }
-
-    changeETagComputation.runEach(
-        c -> {
-          String pluginETag = c.getETag(changeData.project(), changeData.getId());
-          if (pluginETag != null) {
-            h.putString(pluginETag, UTF_8);
-          }
-        });
-  }
-
-  @Override
-  public String getETag() {
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "Compute change ETag",
-            Metadata.builder()
-                .changeId(changeData.getId().get())
-                .projectName(changeData.project().get())
-                .build())) {
-      Hasher h = Hashing.murmur3_128().newHasher();
-      if (user.isIdentifiedUser()) {
-        h.putBoolean(starredChangesReader.isStarred(user.getAccountId(), getId()));
-      }
-      prepareETag(h, user);
-      return h.hash().toString();
-    }
-  }
-
-  private void hashObjectId(Hasher h, @Nullable ObjectId id, byte[] buf) {
-    MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
-    h.putBytes(buf);
-  }
-
-  private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
-    h.putInt(accountState.account().id().get());
-    h.putString(MoreObjects.firstNonNull(accountState.account().metaId(), ZERO_ID_STRING), UTF_8);
-    accountState.externalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
+  public Change.Id getVirtualId() {
+    return getChangeData().virtualId();
   }
 }
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index ba7bd90a..d461a71 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.flogger.LazyArgs.lazy;
-
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
@@ -81,21 +79,20 @@
     ensureDeletable(ctx, id, patchSets);
     // Cleaning up is only possible as long as the change and its elements are
     // still part of the database.
-    cleanUpReferences(id);
+    ChangeData cd = changeDataFactory.create(ctx.getChange());
+    cleanUpReferences(cd);
 
     logger.atFine().log(
         "Deleting change %s, current patch set %d is commit %s",
         id,
         ctx.getChange().currentPatchSetId().get(),
-        lazy(
-            () ->
-                patchSets.stream()
-                    .filter(p -> p.number() == ctx.getChange().currentPatchSetId().get())
-                    .findAny()
-                    .map(p -> p.commitId().name())
-                    .orElse("n/a")));
+        patchSets.stream()
+            .filter(p -> p.number() == ctx.getChange().currentPatchSetId().get())
+            .findAny()
+            .map(p -> p.commitId().name())
+            .orElse("n/a"));
     ctx.deleteChange();
-    changeDeleted.fire(changeDataFactory.create(ctx.getChange()), ctx.getAccount(), ctx.getWhen());
+    changeDeleted.fire(cd, ctx.getAccount(), ctx.getWhen());
     return true;
   }
 
@@ -124,11 +121,11 @@
         revWalk.parseCommit(patchSet.commitId()), revWalk.parseCommit(destId.get()));
   }
 
-  private void cleanUpReferences(Change.Id id) throws IOException {
-    accountPatchReviewStore.run(s -> s.clearReviewed(id));
+  private void cleanUpReferences(ChangeData cd) throws IOException {
+    accountPatchReviewStore.run(s -> s.clearReviewed(cd.virtualId()));
 
     // Non-atomic operation on All-Users refs; not much we can do to make it atomic.
-    starredChangesWriter.unstarAllForChangeDeletion(id);
+    starredChangesWriter.unstarAllForChangeDeletion(cd.virtualId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 2c8ed37..b6eadb6 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -117,7 +118,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
+      throws AuthException, ResourceNotFoundException, PermissionBackendException,
+          ResourceConflictException, IOException {
     Account.Id reviewerId = reviewer.id();
     // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
     removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
@@ -217,7 +219,7 @@
             reviewerDeleted.fire(
                 ctx.getChangeData(currChange),
                 patchSet,
-                accountCache.get(reviewer.id()).orElse(AccountState.forAccount(reviewer)),
+                accountCache.get(reviewer.id()).orElseGet(() -> AccountState.forAccount(reviewer)),
                 ctx.getAccount(),
                 mailMessage,
                 newApprovals,
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index e4118d5..ac22453 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -43,6 +43,9 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -99,12 +102,16 @@
       return null;
     }
 
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelWithStatus> withStatus =
-        cd.change().isMerged()
-            ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
-            : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
-    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get labels", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      LabelTypes labelTypes = cd.getLabelTypes();
+      Map<String, LabelWithStatus> withStatus =
+          cd.change().isMerged()
+              ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
+              : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
+      return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
+    }
   }
 
   /**
@@ -118,30 +125,36 @@
    */
   Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
       throws PermissionBackendException {
-    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    boolean isMerged = cd.change().isMerged();
-    Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
-    for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
-      if (isMerged && !labelType.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;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get permitted labels",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      SetMultimap<String, String> permitted = LinkedHashMultimap.create();
+      boolean isMerged = cd.change().isMerged();
+      Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
+      for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
+        if (isMerged && !labelType.isAllowPostSubmit()) {
+          continue;
         }
-        if (ok) {
-          permitted.put(labelType.getName(), v.formatValue());
+        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;
+          }
+          if (ok) {
+            permitted.put(labelType.getName(), v.formatValue());
+          }
         }
       }
+      clearOnlyZerosEntries(permitted);
+      return permitted.asMap();
     }
-    clearOnlyZerosEntries(permitted);
-    return permitted.asMap();
   }
 
   /**
@@ -156,32 +169,37 @@
   Map<String, Map<String, List<AccountInfo>>> removableLabels(
       AccountLoader accountLoader, CurrentUser user, ChangeData cd)
       throws PermissionBackendException {
-    if (cd.change().isMerged()) {
-      return new HashMap<>();
-    }
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get removable labels",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      if (cd.change().isMerged()) {
+        return new HashMap<>();
+      }
 
-    Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
-    LabelTypes labelTypes = cd.getLabelTypes();
-    for (PatchSetApproval approval : cd.currentApprovals()) {
-      Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
-      if (!labelType.isPresent()) {
-        continue;
+      Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
+      LabelTypes labelTypes = cd.getLabelTypes();
+      for (PatchSetApproval approval : cd.currentApprovals()) {
+        Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
+        if (!labelType.isPresent()) {
+          continue;
+        }
+        if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
+            || removeReviewerControl.testRemoveReviewer(
+                cd, user, approval.accountId(), approval.value()))) {
+          continue;
+        }
+        if (!res.containsKey(approval.label())) {
+          res.put(approval.label(), new HashMap<>());
+        }
+        String labelValue = LabelValue.formatValue(approval.value());
+        if (!res.get(approval.label()).containsKey(labelValue)) {
+          res.get(approval.label()).put(labelValue, new ArrayList<>());
+        }
+        res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
       }
-      if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
-          || removeReviewerControl.testRemoveReviewer(
-              cd, user, approval.accountId(), approval.value()))) {
-        continue;
-      }
-      if (!res.containsKey(approval.label())) {
-        res.put(approval.label(), new HashMap<>());
-      }
-      String labelValue = LabelValue.formatValue(approval.value());
-      if (!res.get(approval.label()).containsKey(labelValue)) {
-        res.get(approval.label()).put(labelValue, new ArrayList<>());
-      }
-      res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
+      return res;
     }
-    return res;
   }
 
   private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
diff --git a/java/com/google/gerrit/server/change/ParentDataProvider.java b/java/com/google/gerrit/server/change/ParentDataProvider.java
index 48ab59d..c0a1ffe 100644
--- a/java/com/google/gerrit/server/change/ParentDataProvider.java
+++ b/java/com/google/gerrit/server/change/ParentDataProvider.java
@@ -98,12 +98,14 @@
   private Optional<ParentCommitData> getFromGerritChange(
       Project.NameKey project, ObjectId parentCommitId, String targetBranch) {
     List<ChangeData> changeData = queryProvider.get().byCommit(parentCommitId.name());
-    if (changeData.size() != 1) {
+    if (changeData.size() > 1) {
       logger.atWarning().log(
-          "Did not find a single change associated with parent revision %s (project: %s). Found changes %s.",
+          "Found more than one change associated with parent revision %s (project: %s). Found changes %s.",
           parentCommitId.name(),
           project.get(),
           changeData.stream().map(ChangeData::getId).collect(ImmutableList.toImmutableList()));
+    }
+    if (changeData.size() != 1) {
       return Optional.empty();
     }
     ChangeData singleData = changeData.get(0);
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 5daac75..2b6f6c6 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -40,12 +40,12 @@
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergeUtilFactory;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -68,6 +68,7 @@
 import org.eclipse.jgit.diff.Sequence;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.merge.MergeResult;
@@ -99,7 +100,6 @@
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeNotes.Factory notesFactory;
-  private final CallerFinder callerFinder;
 
   private final ChangeNotes notes;
   private final PatchSet originalPatchSet;
@@ -123,6 +123,7 @@
   private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private String mergeStrategy;
   private boolean verifyNeedsRebase = true;
+  private final boolean useDiff3;
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -138,6 +139,7 @@
       ChangeNotes.Factory notesFactory,
       GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
+      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
       @Assisted ObjectId baseCommitId) {
@@ -149,6 +151,7 @@
         notesFactory,
         identifiedUserFactory,
         projectCache,
+        cfg,
         notes,
         originalPatchSet);
     this.baseCommitId = baseCommitId;
@@ -164,6 +167,7 @@
       ChangeNotes.Factory notesFactory,
       GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
+      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
       @Assisted Change.Id baseChangeId) {
@@ -175,6 +179,7 @@
         notesFactory,
         identifiedUserFactory,
         projectCache,
+        cfg,
         notes,
         originalPatchSet);
     this.baseChangeId = baseChangeId;
@@ -189,6 +194,7 @@
       ChangeNotes.Factory notesFactory,
       GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
+      @GerritServerConfig Config cfg,
       ChangeNotes notes,
       PatchSet originalPatchSet) {
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -201,7 +207,7 @@
     this.notes = notes;
     this.projectName = notes.getProjectName();
     this.originalPatchSet = originalPatchSet;
-    this.callerFinder = CallerFinder.builder().addTarget(RebaseChangeOp.class).build();
+    this.useDiff3 = cfg.getBoolean("change", null, "diff3ConflictView", false);
   }
 
   @CanIgnoreReturnValue
@@ -500,8 +506,8 @@
       filesWithGitConflicts = null;
       tree = merger.getResultTreeId();
       logger.atFine().log(
-          "tree of rebased commit: %s (no conflicts, inserter: %s, caller: %s)",
-          tree.name(), merger.getObjectInserter(), callerFinder.findCallerLazy());
+          "tree of rebased commit: %s (no conflicts, inserter: %s)",
+          tree.name(), merger.getObjectInserter());
     } else {
       List<String> conflicts = ImmutableList.of();
       Map<String, ResolveMerger.MergeFailureReason> failed = ImmutableMap.of();
@@ -558,10 +564,11 @@
               original,
               "BASE",
               ctx.getRevWalk().parseCommit(base),
-              mergeResults);
+              mergeResults,
+              useDiff3);
       logger.atFine().log(
-          "tree of rebased commit: %s (with conflicts, inserter: %s, caller: %s)",
-          tree.name(), ctx.getInserter(), callerFinder.findCallerLazy());
+          "tree of rebased commit: %s (with conflicts, inserter: %s)",
+          tree.name(), ctx.getInserter());
     }
 
     List<ObjectId> parents = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 93fcbc6..4e487e1 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -420,6 +420,15 @@
       // Gerrit change.
       return ObjectId.fromString(inputBase);
     }
+
+    // Support "refs/heads/..."
+    Ref ref = git.getRefDatabase().exactRef(inputBase);
+    if (ref != null
+        && isBaseRevisionInDestBranch(
+            rw, ObjectId.toString(ref.getObjectId()), git, change.getDest())) {
+      return ref.getObjectId();
+    }
+
     throw new ResourceConflictException(
         "base revision is missing from the destination branch: " + inputBase);
   }
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 2ab7e15..5b63fac 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -65,6 +65,9 @@
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtilFactory;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -258,26 +261,30 @@
       Optional<PatchSet.Id> limitToPsId,
       ChangeInfo changeInfo)
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
-    Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(cd.project());
-        RevWalk rw = newRevWalk(repo)) {
-      for (PatchSet in : map.values()) {
-        PatchSet.Id id = in.id();
-        boolean want;
-        if (has(ALL_REVISIONS)) {
-          want = true;
-        } else if (limitToPsId.isPresent()) {
-          want = id.equals(limitToPsId.get());
-        } else {
-          want = id.equals(cd.change().currentPatchSetId());
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get revisions", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      Map<String, RevisionInfo> res = new LinkedHashMap<>();
+      try (Repository repo = openRepoIfNecessary(cd.project());
+          RevWalk rw = newRevWalk(repo)) {
+        for (PatchSet in : map.values()) {
+          PatchSet.Id id = in.id();
+          boolean want;
+          if (has(ALL_REVISIONS)) {
+            want = true;
+          } else if (limitToPsId.isPresent()) {
+            want = id.equals(limitToPsId.get());
+          } else {
+            want = id.equals(cd.change().currentPatchSetId());
+          }
+          if (want) {
+            res.put(
+                in.commitId().name(),
+                toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
+          }
         }
-        if (want) {
-          res.put(
-              in.commitId().name(),
-              toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
-        }
+        return res;
       }
-      return res;
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index 4a10158..a09cb1f 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -14,26 +14,20 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.TypeLiteral;
 import java.util.Optional;
 
-public class RevisionResource implements RestResource, HasETag {
+public class RevisionResource implements RestResource {
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
       new TypeLiteral<>() {};
 
@@ -90,28 +84,6 @@
     return ps;
   }
 
-  @Override
-  public String getETag() {
-    try (TraceTimer ignored =
-        TraceContext.newTimer(
-            "Compute revision ETag",
-            Metadata.builder()
-                .changeId(changeResource.getId().get())
-                .patchSetId(ps.number())
-                .projectName(changeResource.getProject().get())
-                .build())) {
-      Hasher h = Hashing.murmur3_128().newHasher();
-      prepareETag(h, getUser());
-      return h.hash().toString();
-    }
-  }
-
-  public void prepareETag(Hasher h, CurrentUser user) {
-    // Conservative estimate: refresh the revision if its parent change has changed, so we don't
-    // have to check whether a given modification affected this revision specifically.
-    changeResource.prepareETag(h, user);
-  }
-
   public Account.Id getAccountId() {
     return getUser().getAccountId();
   }
diff --git a/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java b/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
deleted file mode 100644
index 344b9b3..0000000
--- a/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
+++ /dev/null
@@ -1,30 +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.change.testing;
-
-import com.google.gerrit.server.change.ChangeETagComputation;
-
-public class TestChangeETagComputation {
-
-  public static ChangeETagComputation withETag(String etag) {
-    return (p, id) -> etag;
-  }
-
-  public static ChangeETagComputation withException(RuntimeException e) {
-    return (p, id) -> {
-      throw e;
-    };
-  }
-}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
index ce9aa78..85c316d 100644
--- a/java/com/google/gerrit/server/comment/CommentContextKey.java
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -40,7 +40,7 @@
   /** File path at which the comment was written. */
   abstract String path();
 
-  abstract Integer patchset();
+  abstract int patchset();
 
   /** Number of extra lines of context that should be added before and after the comment range. */
   abstract int contextPadding();
@@ -62,9 +62,9 @@
 
     public abstract Builder path(String path);
 
-    public abstract Builder patchset(Integer patchset);
+    public abstract Builder patchset(int patchset);
 
-    public abstract Builder contextPadding(Integer numLines);
+    public abstract Builder contextPadding(int numLines);
 
     public abstract CommentContextKey build();
   }
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index 79e5312..d35767a 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -285,10 +285,10 @@
      * The 1-based line number where the comment is written. A value 0 means that the line number is
      * not available for this comment.
      */
-    abstract Integer lineNumber();
+    abstract int lineNumber();
 
     /** Number of extra lines of context that should be added before and after the comment range. */
-    abstract Integer contextPadding();
+    abstract int contextPadding();
 
     @AutoValue.Builder
     public abstract static class Builder {
@@ -299,9 +299,9 @@
 
       public abstract Builder range(@Nullable Comment.Range range);
 
-      public abstract Builder lineNumber(Integer lineNumber);
+      public abstract Builder lineNumber(int lineNumber);
 
-      public abstract Builder contextPadding(Integer contextPadding);
+      public abstract Builder contextPadding(int contextPadding);
 
       public abstract ContextInput build();
     }
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index b6ffcee..fad4978 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -62,6 +62,7 @@
   private final String cookiePath;
   private final String cookieDomain;
   private final boolean cookieSecure;
+  private final boolean cookieHttpOnly;
   private final SignedToken emailReg;
   private final boolean allowRegisterNewEmail;
   private final boolean userNameCaseInsensitive;
@@ -91,6 +92,7 @@
     cookiePath = cfg.getString("auth", null, "cookiepath");
     cookieDomain = cfg.getString("auth", null, "cookiedomain");
     cookieSecure = cfg.getBoolean("auth", "cookiesecure", false);
+    cookieHttpOnly = cfg.getBoolean("auth", "cookiehttponly", true);
     trustContainerAuth = cfg.getBoolean("auth", "trustContainerAuth", false);
     enableRunAs = cfg.getBoolean("auth", null, "enableRunAs", true);
     gitBasicAuthPolicy = getBasicAuthPolicy(cfg);
@@ -218,6 +220,10 @@
     return cookieSecure;
   }
 
+  public boolean getCookieHttpOnly() {
+    return cookieHttpOnly;
+  }
+
   public SignedToken getEmailRegistrationToken() {
     return emailReg;
   }
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 169d9ec..3b013c0 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -23,6 +22,10 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.gerrit.server.cache.proto.Cache.CachedPreferencesProto;
+import com.google.gerrit.server.config.PreferencesParserUtil.DiffPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.EditPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.GeneralPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.PreferencesParser;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -44,6 +47,7 @@
   public Optional<CachedPreferencesProto> nonEmptyConfig() {
     return config().equals(EMPTY.config()) ? Optional.empty() : Optional.of(config());
   }
+
   /** Returns a cache-able representation of the preferences proto. */
   public static CachedPreferences fromUserPreferencesProto(UserPreferences proto) {
     return fromCachedPreferencesProto(
@@ -67,38 +71,17 @@
 
   public static GeneralPreferencesInfo general(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseGeneralPreferences,
-        p ->
-            UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
-                p.getGeneralPreferencesInfo()),
-        GeneralPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, GeneralPreferencesParser.Instance);
   }
 
   public static DiffPreferencesInfo diff(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseDiffPreferences,
-        p ->
-            UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
-                p.getDiffPreferencesInfo()),
-        DiffPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, DiffPreferencesParser.Instance);
   }
 
   public static EditPreferencesInfo edit(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseEditPreferences,
-        p ->
-            UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
-                p.getEditPreferencesInfo()),
-        EditPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, EditPreferencesParser.Instance);
   }
 
   public Config asConfig() {
@@ -135,34 +118,25 @@
     return cachedPreferences.map(CachedPreferences::asConfig).orElse(null);
   }
 
-  @FunctionalInterface
-  private interface ComputePreferencesFn<PreferencesT> {
-    PreferencesT apply(Config cfg, @Nullable Config defaultCfg, @Nullable PreferencesT input)
-        throws ConfigInvalidException;
-  }
-
   private static <PreferencesT> PreferencesT getPreferences(
       Optional<CachedPreferences> defaultPreferences,
       CachedPreferences userPreferences,
-      ComputePreferencesFn<PreferencesT> computePreferencesFn,
-      Function<UserPreferences, PreferencesT> fromUserPreferencesFn,
-      PreferencesT javaDefaults) {
+      PreferencesParser<PreferencesT> preferencesParser) {
     try {
       CachedPreferencesProto userPreferencesProto = userPreferences.config();
       switch (userPreferencesProto.getPreferencesCase()) {
         case USER_PREFERENCES:
-          PreferencesT pref =
-              fromUserPreferencesFn.apply(userPreferencesProto.getUserPreferences());
-          return computePreferencesFn.apply(new Config(), configOrNull(defaultPreferences), pref);
+          return preferencesParser.fromUserPreferences(
+              userPreferencesProto.getUserPreferences(), configOrNull(defaultPreferences));
         case LEGACY_GIT_CONFIG:
-          return computePreferencesFn.apply(
+          return preferencesParser.parse(
               userPreferences.asConfig(), configOrNull(defaultPreferences), null);
         case PREFERENCES_NOT_SET:
           throw new ConfigInvalidException("Invalid config " + userPreferences);
       }
     } catch (ConfigInvalidException e) {
-      return javaDefaults;
+      return preferencesParser.getJavaDefaults();
     }
-    return javaDefaults;
+    return preferencesParser.getJavaDefaults();
   }
 }
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 44bfa48..e76207c 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -397,6 +397,50 @@
   }
 
   /**
+   * Merges config by inspecting Java class attributes, similar to {@link #loadSection}.
+   *
+   * <p>Config values are stored optimized: no default values are stored. The loading is performed
+   * eagerly: all values are set, except default boolean values.
+   *
+   * <p>Fields marked with final or transient modifiers are skipped.
+   *
+   * @param cfg config from which the values are loaded
+   * @param s instance of class in which the values are set
+   * @param defaults instance of class with default values
+   * @return loaded instance
+   */
+  @CanIgnoreReturnValue
+  public static <T> T mergeWithDefaults(T cfg, T s, T defaults) throws ConfigInvalidException {
+    try {
+      for (Field f : s.getClass().getDeclaredFields()) {
+        if (skipField(f)) {
+          continue;
+        }
+        Class<?> t = f.getType();
+        String n = f.getName();
+        f.setAccessible(true);
+
+        Object val = f.get(cfg);
+        if (val == null) {
+          val = f.get(defaults);
+          if (!isString(t) && !isCollectionOrMap(t)) {
+            requireNonNull(val, "Default cannot be null for: " + n);
+          }
+        }
+        if (!isBoolean(t) || (boolean) val) {
+          // To reproduce the same behavior as in the loadSection method above, values are
+          // explicitly set for all types, except the boolean type. For the boolean type, the value
+          // is set only if it is 'true' (so, the false value is omitted in the result object).
+          f.set(s, val);
+        }
+      }
+    } catch (SecurityException | IllegalArgumentException | IllegalAccessException e) {
+      throw new ConfigInvalidException("cannot load values", e);
+    }
+    return s;
+  }
+
+  /**
    * Update user config by applying the specified delta
    *
    * <p>As opposed to {@link com.google.gerrit.server.config.ConfigUtil#storeSection}, this method
diff --git a/java/com/google/gerrit/server/config/ExperimentResource.java b/java/com/google/gerrit/server/config/ExperimentResource.java
new file mode 100644
index 0000000..e22ffc7
--- /dev/null
+++ b/java/com/google/gerrit/server/config/ExperimentResource.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class ExperimentResource extends ConfigResource {
+  public static final TypeLiteral<RestView<ExperimentResource>> EXPERIMENT_KIND =
+      new TypeLiteral<>() {};
+
+  private final String name;
+
+  public ExperimentResource(String name) {
+    this.name = name;
+  }
+
+  public String getName() {
+    return name;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index a38a3fc..1f0bd6e 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -89,13 +89,16 @@
 import com.google.gerrit.server.ExceptionHookImpl;
 import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PerformanceMetrics;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.ServerStateProvider;
 import com.google.gerrit.server.TraceRequestListener;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountModule;
+import com.google.gerrit.server.account.AccountStateProvider;
 import com.google.gerrit.server.account.AccountTagProvider;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.CapabilityCollection;
@@ -106,7 +109,6 @@
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheModule;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -114,7 +116,6 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
@@ -187,8 +188,8 @@
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.project.AccessControlModule;
 import com.google.gerrit.server.project.CommentLinkProvider;
+import com.google.gerrit.server.project.LockManager;
 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.PrologRulesWarningValidator;
 import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
@@ -217,6 +218,7 @@
 import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.SubmitRequirementLabelExtensionPredicate;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
@@ -277,7 +279,6 @@
     install(new AccessControlModule());
     install(new AccountModule());
     install(new CmdLineParserModule());
-    install(new ExternalIdCacheModule());
     install(new ExternalIdModule());
     install(new GitModule());
     install(new GroupDbModule());
@@ -301,6 +302,7 @@
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(DistinctVotersPredicate.Factory.class);
+    factory(SubmitRequirementLabelExtensionPredicate.Factory.class);
     factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
     factory(EmailNewPatchSet.Factory.class);
@@ -451,18 +453,20 @@
     DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
     DynamicSet.setOf(binder(), ActionVisitor.class);
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
-    DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
+    DynamicItem.itemOf(binder(), LockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
     DynamicSet.setOf(binder(), SubmitRequirement.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
+    DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
-    DynamicSet.setOf(binder(), ChangeETagComputation.class);
     DynamicSet.setOf(binder(), ExceptionHook.class);
     DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
     DynamicSet.setOf(binder(), OnPostReview.class);
+    DynamicSet.setOf(binder(), ServerStateProvider.class);
+    DynamicSet.setOf(binder(), AccountStateProvider.class);
     DynamicMap.mapOf(binder(), AccountTagProvider.class);
     DynamicSet.setOf(binder(), AttentionSetListener.class);
 
diff --git a/java/com/google/gerrit/server/config/GerritIsReplicaProvider.java b/java/com/google/gerrit/server/config/GerritIsReplicaProvider.java
index 213cd1c..f53d718 100644
--- a/java/com/google/gerrit/server/config/GerritIsReplicaProvider.java
+++ b/java/com/google/gerrit/server/config/GerritIsReplicaProvider.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -25,17 +24,18 @@
  *
  * <p>The returned boolean indicates whether Gerrit is run as a read-only replica.
  */
-@Singleton
 public final class GerritIsReplicaProvider implements Provider<Boolean> {
   private final Config config;
+  private final boolean replicaOption;
 
   @Inject
-  public GerritIsReplicaProvider(@GerritServerConfig Config config) {
+  public GerritIsReplicaProvider(@GerritServerConfig Config config, GerritOptions opts) {
     this.config = config;
+    this.replicaOption = opts.replica();
   }
 
   @Override
   public Boolean get() {
-    return ReplicaUtil.isReplica(config);
+    return replicaOption || ReplicaUtil.isReplica(config);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
index 0390620..30633a5 100644
--- a/java/com/google/gerrit/server/config/GerritOptions.java
+++ b/java/com/google/gerrit/server/config/GerritOptions.java
@@ -20,16 +20,18 @@
 
 public class GerritOptions {
   private final boolean headless;
-  private final boolean slave;
+  private final boolean replica;
   private final Optional<String> devCdn;
 
-  public GerritOptions(boolean headless, boolean slave) {
-    this(headless, slave, null);
+  public static GerritOptions DEFAULT = new GerritOptions(false, false);
+
+  public GerritOptions(boolean headless, boolean replica) {
+    this(headless, replica, null);
   }
 
-  public GerritOptions(boolean headless, boolean slave, @Nullable String devCdn) {
+  public GerritOptions(boolean headless, boolean replica, @Nullable String devCdn) {
     this.headless = headless;
-    this.slave = slave;
+    this.replica = replica;
     this.devCdn = headless ? Optional.empty() : Optional.ofNullable(Strings.emptyToNull(devCdn));
   }
 
@@ -37,8 +39,12 @@
     return headless;
   }
 
+  public boolean replica() {
+    return replica;
+  }
+
   public boolean enableMasterFeatures() {
-    return !slave;
+    return !replica;
   }
 
   public Optional<String> devCdn() {
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 8ddcdac..b811834 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -71,6 +71,7 @@
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(GerritIsReplica.class)
-        .toProvider(GerritIsReplicaProvider.class);
+        .toProvider(GerritIsReplicaProvider.class)
+        .in(SINGLETON);
   }
 }
diff --git a/java/com/google/gerrit/server/config/IndexResource.java b/java/com/google/gerrit/server/config/IndexResource.java
index a65cc52..30d39c4 100644
--- a/java/com/google/gerrit/server/config/IndexResource.java
+++ b/java/com/google/gerrit/server/config/IndexResource.java
@@ -14,44 +14,21 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.inject.TypeLiteral;
-import java.util.Collection;
-import java.util.List;
 
 public class IndexResource extends ConfigResource {
   public static final TypeLiteral<RestView<IndexResource>> INDEX_KIND = new TypeLiteral<>() {};
 
-  private final ImmutableList<Index<?, ?>> indexes;
+  private IndexDefinition<?, ?, ?> def;
 
-  public IndexResource(IndexCollection<?, ?, ?> indexes, @Nullable Integer version)
-      throws ResourceNotFoundException {
-    if (version == null) {
-      this.indexes = ImmutableList.copyOf(indexes.getWriteIndexes());
-    } else {
-      Index<?, ?> index = indexes.getWriteIndex(version);
-      if (index == null) {
-        throw new ResourceNotFoundException(
-            String.format("Unknown index version requested: %d", version));
-      }
-      this.indexes = ImmutableList.of(index);
-    }
+  public IndexResource(IndexDefinition<?, ?, ?> def) {
+    this.def = def;
   }
 
-  public IndexResource(List<IndexCollection<?, ?, ?>> indexes) {
-    ImmutableList.Builder<Index<?, ?>> allIndexes = ImmutableList.builder();
-    for (IndexCollection<?, ?, ?> index : indexes) {
-      allIndexes.addAll(index.getWriteIndexes());
-    }
-    this.indexes = allIndexes.build();
-  }
-
-  public Collection<Index<?, ?>> getIndexes() {
-    return indexes;
+  public IndexDefinition<?, ?, ? extends Index<?, ?>> getIndexDefinition() {
+    return def;
   }
 }
diff --git a/java/com/google/gerrit/server/config/IndexVersionResource.java b/java/com/google/gerrit/server/config/IndexVersionResource.java
new file mode 100644
index 0000000..8ccb0b0
--- /dev/null
+++ b/java/com/google/gerrit/server/config/IndexVersionResource.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.inject.TypeLiteral;
+
+public class IndexVersionResource implements RestResource {
+  public static final TypeLiteral<RestView<IndexVersionResource>> INDEX_VERSION_KIND =
+      new TypeLiteral<>() {};
+
+  private final IndexDefinition<?, ?, ?> def;
+  private final Index<?, ?> index;
+
+  public IndexVersionResource(IndexDefinition<?, ?, ?> def, Index<?, ?> index) {
+    this.def = def;
+    this.index = index;
+  }
+
+  public IndexDefinition<?, ?, ?> getIndexDefinition() {
+    return def;
+  }
+
+  public Index<?, ?> getIndex() {
+    return index;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index fbdb324..3e39e25 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -15,7 +15,11 @@
 package com.google.gerrit.server.config;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.mergeWithDefaults;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter.DIFF_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter.EDIT_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter.GENERAL_PREFERENCES_INFO_CONVERTER;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
@@ -30,6 +34,7 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.gerrit.server.git.UserConfigSections;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -66,13 +71,31 @@
       r.my = input.my;
     } else {
       r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
-      r.my = parseMyMenus(cfg, defaultCfg);
+      r.my = parseMyMenus(my(cfg), defaultCfg);
     }
     return r;
   }
 
   /**
    * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
+   * the server's default configs and {@code cfg} for the user's config.
+   */
+  public static GeneralPreferencesInfo parseGeneralPreferences(
+      GeneralPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    GeneralPreferencesInfo r =
+        mergeWithDefaults(
+            cfg,
+            new GeneralPreferencesInfo(),
+            defaultCfg != null
+                ? parseDefaultGeneralPreferences(defaultCfg, null)
+                : GeneralPreferencesInfo.defaults());
+    r.changeTable = cfg.changeTable != null ? cfg.changeTable : Lists.newArrayList();
+    r.my = parseMyMenus(cfg.my, defaultCfg);
+    return r;
+  }
+
+  /**
+   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
    * the server's default configs. These configs are then overlaid to inherit values (default ->
    * input (if provided).
    */
@@ -110,6 +133,20 @@
 
   /**
    * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config.
+   */
+  public static DiffPreferencesInfo parseDiffPreferences(
+      DiffPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    return mergeWithDefaults(
+        cfg,
+        new DiffPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultDiffPreferences(defaultCfg, null)
+            : DiffPreferencesInfo.defaults());
+  }
+
+  /**
+   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
    * server's default configs. These configs are then overlaid to inherit values (default -> input
    * (if provided).
    */
@@ -147,6 +184,20 @@
 
   /**
    * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config.
+   */
+  public static EditPreferencesInfo parseEditPreferences(
+      EditPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    return mergeWithDefaults(
+        cfg,
+        new EditPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultEditPreferences(defaultCfg, null)
+            : EditPreferencesInfo.defaults());
+  }
+
+  /**
+   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
    * server's default configs. These configs are then overlaid to inherit values (default -> input
    * (if provided).
    */
@@ -171,11 +222,14 @@
     return changeTable;
   }
 
-  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
-    List<MenuItem> my = my(cfg);
-    if (my.isEmpty() && defaultCfg != null) {
+  private static List<MenuItem> parseMyMenus(
+      @Nullable List<MenuItem> my, @Nullable Config defaultCfg) {
+    if (defaultCfg != null && (my == null || my.isEmpty())) {
       my = my(defaultCfg);
     }
+    if (my == null) {
+      my = new ArrayList<>();
+    }
     if (my.isEmpty()) {
       my.add(new MenuItem("Dashboard", "#/dashboard/self", null));
       my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
@@ -264,4 +318,94 @@
     String val = cfg.getString(UserConfigSections.MY, subsection, key);
     return !Strings.isNullOrEmpty(val) ? val : defaultValue;
   }
+
+  /** Provides methods for parsing user configs */
+  interface PreferencesParser<T> {
+    T parse(Config cfg, @Nullable Config defaultConfig, @Nullable T input)
+        throws ConfigInvalidException;
+
+    T fromUserPreferences(UserPreferences userPreferences, @Nullable Config defaultCfg)
+        throws ConfigInvalidException;
+
+    T getJavaDefaults();
+  }
+
+  /** Provides methods for parsing GeneralPreferencesInfo configs */
+  public static class GeneralPreferencesParser
+      implements PreferencesParser<GeneralPreferencesInfo> {
+    public static GeneralPreferencesParser Instance = new GeneralPreferencesParser();
+
+    private GeneralPreferencesParser() {}
+
+    @Override
+    public GeneralPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public GeneralPreferencesInfo fromUserPreferences(
+        UserPreferences p, @Nullable Config defaultCfg) throws ConfigInvalidException {
+      return PreferencesParserUtil.parseGeneralPreferences(
+          GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(p.getGeneralPreferencesInfo()), defaultCfg);
+    }
+
+    @Override
+    public GeneralPreferencesInfo getJavaDefaults() {
+      return GeneralPreferencesInfo.defaults();
+    }
+  }
+
+  /** Provides methods for parsing EditPreferencesInfo configs */
+  public static class EditPreferencesParser implements PreferencesParser<EditPreferencesInfo> {
+    public static EditPreferencesParser Instance = new EditPreferencesParser();
+
+    private EditPreferencesParser() {}
+
+    @Override
+    public EditPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public EditPreferencesInfo fromUserPreferences(UserPreferences p, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseEditPreferences(
+          EDIT_PREFERENCES_INFO_CONVERTER.fromProto(p.getEditPreferencesInfo()), defaultCfg);
+    }
+
+    @Override
+    public EditPreferencesInfo getJavaDefaults() {
+      return EditPreferencesInfo.defaults();
+    }
+  }
+
+  /** Provides methods for parsing DiffPreferencesInfo configs */
+  public static class DiffPreferencesParser implements PreferencesParser<DiffPreferencesInfo> {
+    public static DiffPreferencesParser Instance = new DiffPreferencesParser();
+
+    private DiffPreferencesParser() {}
+
+    @Override
+    public DiffPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public DiffPreferencesInfo fromUserPreferences(UserPreferences p, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseDiffPreferences(
+          DIFF_PREFERENCES_INFO_CONVERTER.fromProto(p.getDiffPreferencesInfo()), defaultCfg);
+    }
+
+    @Override
+    public DiffPreferencesInfo getJavaDefaults() {
+      return DiffPreferencesInfo.defaults();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index e11d6aa..1ff0a8b 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -44,7 +44,7 @@
   private final String displayName;
   private final String description;
   private final boolean inheritable;
-  private final String defaultValue;
+  @Nullable private final String defaultValue;
   private final ProjectConfigEntryType type;
   private final List<String> permittedValues;
 
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index 4a052d7..4b2f6d2 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -16,12 +16,15 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.converter.SafeProtoConverter;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.protobuf.Message;
+import com.google.protobuf.Parser;
 import com.google.protobuf.ProtocolMessageEnum;
 import java.util.function.Function;
 
@@ -32,8 +35,13 @@
  * equivalents in Spanner.
  */
 public final class UserPreferencesConverter {
-  public static final class GeneralPreferencesInfoConverter {
-    public static UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
+  public enum GeneralPreferencesInfoConverter
+      implements
+          SafeProtoConverter<UserPreferences.GeneralPreferencesInfo, GeneralPreferencesInfo> {
+    GENERAL_PREFERENCES_INFO_CONVERTER;
+
+    @Override
+    public UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
       UserPreferences.GeneralPreferencesInfo.Builder builder =
           UserPreferences.GeneralPreferencesInfo.newBuilder();
       builder = setIfNotNull(builder, builder::setChangesPerPage, info.changesPerPage);
@@ -111,11 +119,20 @@
       builder =
           setIfNotNull(
               builder, builder::setAllowBrowserNotifications, info.allowBrowserNotifications);
+      builder =
+          setIfNotNull(
+              builder,
+              builder::setAllowSuggestCodeWhileCommenting,
+              info.allowSuggestCodeWhileCommenting);
+      builder =
+          setIfNotNull(
+              builder, builder::setAllowAutocompletingComments, info.allowAutocompletingComments);
       builder = setIfNotNull(builder, builder::setDiffPageSidebar, info.diffPageSidebar);
       return builder.build();
     }
 
-    public static GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
+    @Override
+    public GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
       GeneralPreferencesInfo res = new GeneralPreferencesInfo();
       res.changesPerPage = proto.hasChangesPerPage() ? proto.getChangesPerPage() : null;
       res.downloadScheme = proto.hasDownloadScheme() ? proto.getDownloadScheme() : null;
@@ -172,35 +189,62 @@
       res.changeTable = proto.getChangeTableCount() != 0 ? proto.getChangeTableList() : null;
       res.allowBrowserNotifications =
           proto.hasAllowBrowserNotifications() ? proto.getAllowBrowserNotifications() : null;
+      res.allowSuggestCodeWhileCommenting =
+          proto.hasAllowSuggestCodeWhileCommenting()
+              ? proto.getAllowSuggestCodeWhileCommenting()
+              : null;
+      res.allowAutocompletingComments =
+          proto.hasAllowAutocompletingComments() ? proto.getAllowAutocompletingComments() : null;
       res.diffPageSidebar = proto.hasDiffPageSidebar() ? proto.getDiffPageSidebar() : null;
       return res;
     }
 
+    @Override
+    public Parser<UserPreferences.GeneralPreferencesInfo> getParser() {
+      return UserPreferences.GeneralPreferencesInfo.parser();
+    }
+
     private static UserPreferences.GeneralPreferencesInfo.MenuItem menuItemToProto(
         MenuItem javaItem) {
       UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder =
           UserPreferences.GeneralPreferencesInfo.MenuItem.newBuilder();
-      builder = setIfNotNull(builder, builder::setName, javaItem.name);
-      builder = setIfNotNull(builder, builder::setUrl, javaItem.url);
-      builder = setIfNotNull(builder, builder::setTarget, javaItem.target);
-      builder = setIfNotNull(builder, builder::setId, javaItem.id);
+      builder = setIfNotNull(builder, builder::setName, trimSafe(javaItem.name));
+      builder = setIfNotNull(builder, builder::setUrl, trimSafe(javaItem.url));
+      builder = setIfNotNull(builder, builder::setTarget, trimSafe(javaItem.target));
+      builder = setIfNotNull(builder, builder::setId, trimSafe(javaItem.id));
       return builder.build();
     }
 
+    private static @Nullable String trimSafe(@Nullable String s) {
+      return s == null ? s : s.trim();
+    }
+
     private static MenuItem menuItemFromProto(
         UserPreferences.GeneralPreferencesInfo.MenuItem proto) {
       return new MenuItem(
-          proto.hasName() ? proto.getName() : null,
-          proto.hasUrl() ? proto.getUrl() : null,
-          proto.hasTarget() ? proto.getTarget() : null,
-          proto.hasId() ? proto.getId() : null);
+          proto.hasName() ? proto.getName().trim() : null,
+          proto.hasUrl() ? proto.getUrl().trim() : null,
+          proto.hasTarget() ? proto.getTarget().trim() : null,
+          proto.hasId() ? proto.getId().trim() : null);
     }
 
-    private GeneralPreferencesInfoConverter() {}
+    @Override
+    public Class<UserPreferences.GeneralPreferencesInfo> getProtoClass() {
+      return UserPreferences.GeneralPreferencesInfo.class;
+    }
+
+    @Override
+    public Class<GeneralPreferencesInfo> getEntityClass() {
+      return GeneralPreferencesInfo.class;
+    }
   }
 
-  public static final class DiffPreferencesInfoConverter {
-    public static UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
+  public enum DiffPreferencesInfoConverter
+      implements SafeProtoConverter<UserPreferences.DiffPreferencesInfo, DiffPreferencesInfo> {
+    DIFF_PREFERENCES_INFO_CONVERTER;
+
+    @Override
+    public UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
       UserPreferences.DiffPreferencesInfo.Builder builder =
           UserPreferences.DiffPreferencesInfo.newBuilder();
       builder = setIfNotNull(builder, builder::setContext, info.context);
@@ -236,7 +280,8 @@
       return builder.build();
     }
 
-    public static DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
+    @Override
+    public DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
       DiffPreferencesInfo res = new DiffPreferencesInfo();
       res.context = proto.hasContext() ? proto.getContext() : null;
       res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
@@ -271,11 +316,28 @@
       return res;
     }
 
-    private DiffPreferencesInfoConverter() {}
+    @Override
+    public Parser<UserPreferences.DiffPreferencesInfo> getParser() {
+      return UserPreferences.DiffPreferencesInfo.parser();
+    }
+
+    @Override
+    public Class<UserPreferences.DiffPreferencesInfo> getProtoClass() {
+      return UserPreferences.DiffPreferencesInfo.class;
+    }
+
+    @Override
+    public Class<DiffPreferencesInfo> getEntityClass() {
+      return DiffPreferencesInfo.class;
+    }
   }
 
-  public static final class EditPreferencesInfoConverter {
-    public static UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
+  public enum EditPreferencesInfoConverter
+      implements SafeProtoConverter<UserPreferences.EditPreferencesInfo, EditPreferencesInfo> {
+    EDIT_PREFERENCES_INFO_CONVERTER;
+
+    @Override
+    public UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
       UserPreferences.EditPreferencesInfo.Builder builder =
           UserPreferences.EditPreferencesInfo.newBuilder();
       builder = setIfNotNull(builder, builder::setTabSize, info.tabSize);
@@ -295,7 +357,8 @@
       return builder.build();
     }
 
-    public static EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
+    @Override
+    public EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
       EditPreferencesInfo res = new EditPreferencesInfo();
       res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
       res.lineLength = proto.hasLineLength() ? proto.getLineLength() : null;
@@ -315,7 +378,20 @@
       return res;
     }
 
-    private EditPreferencesInfoConverter() {}
+    @Override
+    public Parser<UserPreferences.EditPreferencesInfo> getParser() {
+      return UserPreferences.EditPreferencesInfo.parser();
+    }
+
+    @Override
+    public Class<UserPreferences.EditPreferencesInfo> getProtoClass() {
+      return UserPreferences.EditPreferencesInfo.class;
+    }
+
+    @Override
+    public Class<EditPreferencesInfo> getEntityClass() {
+      return EditPreferencesInfo.class;
+    }
   }
 
   private static <ValueT, BuilderT extends Message.Builder> BuilderT setIfNotNull(
diff --git a/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
index bea6dd3..45a9ddf 100644
--- a/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
+++ b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
@@ -16,13 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -30,11 +28,9 @@
  * Low-level storage API to load Gerrit's default config from {@code All-Users}. Should not be used
  * directly.
  */
-public class VersionedDefaultPreferences extends VersionedMetaData {
+public class VersionedDefaultPreferences extends VersionedConfigFile {
   private static final String PREFERENCES_CONFIG = "preferences.config";
 
-  private Config cfg;
-
   public static Config get(Repository allUsersRepo, AllUsersName allUsersName)
       throws StorageException, ConfigInvalidException {
     VersionedDefaultPreferences versionedDefaultPreferences = new VersionedDefaultPreferences();
@@ -46,27 +42,13 @@
     return versionedDefaultPreferences.getConfig();
   }
 
-  @Override
-  protected String getRefName() {
-    return RefNames.REFS_USERS_DEFAULT;
+  public VersionedDefaultPreferences() {
+    super(RefNames.REFS_USERS_DEFAULT, PREFERENCES_CONFIG, "Update default preferences\n");
   }
 
+  @Override
   public Config getConfig() {
     checkState(cfg != null, "Default preferences not loaded yet.");
     return cfg;
   }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    cfg = readConfig(PREFERENCES_CONFIG);
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException {
-    if (Strings.isNullOrEmpty(commit.getMessage())) {
-      commit.setMessage("Update default preferences\n");
-    }
-    saveConfig(PREFERENCES_CONFIG, cfg);
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/data/PatchSetAttribute.java b/java/com/google/gerrit/server/data/PatchSetAttribute.java
index dc47057..3f7c8e4 100644
--- a/java/com/google/gerrit/server/data/PatchSetAttribute.java
+++ b/java/com/google/gerrit/server/data/PatchSetAttribute.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.util.List;
 
-public class PatchSetAttribute {
+public class PatchSetAttribute implements Cloneable {
   public int number;
   public String revision;
   public List<String> parents;
@@ -32,4 +32,12 @@
   public List<PatchAttribute> files;
   public int sizeInsertions;
   public int sizeDeletions;
+
+  public PatchSetAttribute shallowClone() {
+    try {
+      return (PatchSetAttribute) super.clone();
+    } catch (CloneNotSupportedException e) {
+      throw new AssertionError(e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index d9a118d..1030baa 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.edit;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
-import com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -26,6 +28,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeEditIdentityType;
+import com.google.gerrit.extensions.api.changes.RebaseChangeEditInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -38,6 +41,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.DeleteFileModification;
 import com.google.gerrit.server.edit.tree.RenameFileModification;
@@ -45,6 +49,9 @@
 import com.google.gerrit.server.edit.tree.TreeCreator;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -59,18 +66,23 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.diff.Sequence;
+import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.InvalidPathException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -81,9 +93,9 @@
 import org.eclipse.jgit.merge.MergeChunk;
 import org.eclipse.jgit.merge.MergeResult;
 import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -106,9 +118,11 @@
   private final ProjectCache projectCache;
   private final NoteDbEdits noteDbEdits;
   private final ChangeUtil changeUtil;
+  private final boolean useDiff3;
 
   @Inject
   ChangeEditModifier(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent gerritIdent,
       ChangeIndexer indexer,
       Provider<CurrentUser> currentUser,
@@ -126,6 +140,9 @@
     this.projectCache = projectCache;
     noteDbEdits = new NoteDbEdits(gitReferenceUpdated, zoneId, indexer, currentUser);
     this.changeUtil = changeUtil;
+    this.useDiff3 =
+        cfg.getBoolean(
+            "change", /* subsection= */ null, "diff3ConflictView", /* defaultValue= */ false);
   }
 
   /**
@@ -157,12 +174,15 @@
    *
    * @param repository the affected Git repository
    * @param notes the {@link ChangeNotes} of the change whose change edit should be rebased
+   * @param input the request input
+   * @return the rebased change edit commit
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
    *     change, the change edit is already based on the latest patch set, or the change represents
    *     the root commit
    */
-  public void rebaseEdit(Repository repository, ChangeNotes notes)
+  public CodeReviewCommit rebaseEdit(
+      Repository repository, ChangeNotes notes, RebaseChangeEditInput input)
       throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     assertCanEdit(notes);
@@ -182,14 +202,16 @@
               notes.getChangeId(), currentPatchSet.id()));
     }
 
-    rebase(notes.getProjectName(), repository, changeEdit, currentPatchSet);
+    return rebase(
+        notes.getProjectName(), repository, changeEdit, currentPatchSet, input.allowConflicts);
   }
 
-  private void rebase(
+  private CodeReviewCommit rebase(
       Project.NameKey project,
       Repository repository,
       ChangeEdit changeEdit,
-      PatchSet currentPatchSet)
+      PatchSet currentPatchSet,
+      boolean allowConflicts)
       throws IOException, MergeConflictException, InvalidChangeOperationException {
     RevCommit currentEditCommit = changeEdit.getEditCommit();
     if (currentEditCommit.getParentCount() == 0) {
@@ -197,20 +219,10 @@
           "Rebase change edit against root commit not supported");
     }
 
-    RevCommit basePatchSetCommit = NoteDbEdits.lookupCommit(repository, currentPatchSet.commitId());
-    RevTree basePatchSetTree = basePatchSetCommit.getTree();
-
-    ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
     Instant nowTimestamp = TimeUtil.now();
-    String commitMessage = currentEditCommit.getFullMessage();
-    ObjectId newEditCommitId =
-        createCommit(
-            repository,
-            basePatchSetCommit,
-            newTreeId,
-            commitMessage,
-            currentEditCommit.getAuthorIdent(),
-            new PersonIdent(currentEditCommit.getCommitterIdent(), nowTimestamp));
+    RevCommit basePatchSetCommit = NoteDbEdits.lookupCommit(repository, currentPatchSet.commitId());
+    CodeReviewCommit newEditCommit =
+        merge(repository, changeEdit, basePatchSetCommit, nowTimestamp, allowConflicts);
 
     noteDbEdits.baseEditOnDifferentPatchset(
         project,
@@ -218,8 +230,10 @@
         changeEdit,
         currentPatchSet,
         currentEditCommit,
-        newEditCommitId,
+        newEditCommit,
         nowTimestamp);
+
+    return newEditCommit;
   }
 
   /**
@@ -532,7 +546,7 @@
     return editBasePatchSet.id().equals(patchSet.id());
   }
 
-  private static ObjectId createNewTree(
+  public static ObjectId createNewTree(
       Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
       throws BadRequestException, IOException, InvalidChangeOperationException {
     if (treeModifications.isEmpty()) {
@@ -554,21 +568,106 @@
     return newTreeId;
   }
 
-  private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
+  private CodeReviewCommit merge(
+      Repository repository,
+      ChangeEdit changeEdit,
+      RevCommit basePatchSetCommit,
+      Instant timestamp,
+      boolean allowConflicts)
       throws IOException, MergeConflictException {
     PatchSet basePatchSet = changeEdit.getBasePatchSet();
     ObjectId basePatchSetCommitId = basePatchSet.commitId();
     ObjectId editCommitId = changeEdit.getEditCommit();
 
-    ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repository, true);
-    threeWayMerger.setBase(basePatchSetCommitId);
-    boolean successful = threeWayMerger.merge(newTreeId, editCommitId);
+    try (CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(repository);
+        ObjectInserter objectInserter = repository.newObjectInserter()) {
+      ThreeWayMerger merger = MergeStrategy.RESOLVE.newMerger(repository, true);
+      merger.setBase(basePatchSetCommitId);
+
+      DirCache dc = DirCache.newInCore();
+      if (allowConflicts && merger instanceof ResolveMerger) {
+        // The DirCache must be set on ResolveMerger before calling
+        // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get
+        // populated.
+        ((ResolveMerger) merger).setDirCache(dc);
+      }
+
+      boolean successful = merger.merge(basePatchSetCommit, editCommitId);
+
+      ObjectId newTreeId;
+      ImmutableSet<String> filesWithGitConflicts;
+      if (successful) {
+        newTreeId = merger.getResultTreeId();
+        filesWithGitConflicts = null;
+      } else {
+        List<String> conflicts = ImmutableList.of();
+        if (merger instanceof ResolveMerger) {
+          conflicts = ((ResolveMerger) merger).getUnmergedPaths();
+        }
+
+        if (!allowConflicts || !(merger instanceof ResolveMerger)) {
+          throw new MergeConflictException(
+              String.format(
+                  "Rebasing change edit onto another patchset results in merge conflicts.\n\n"
+                      + "%s\n\n"
+                      + "Download the edit patchset and rebase manually to preserve changes.",
+                  MergeUtil.createConflictMessage(conflicts)));
+        }
+
+        Map<String, MergeResult<? extends Sequence>> mergeResults =
+            ((ResolveMerger) merger).getMergeResults();
+        filesWithGitConflicts =
+            mergeResults.entrySet().stream()
+                .filter(e -> e.getValue().containsConflicts())
+                .map(Map.Entry::getKey)
+                .collect(toImmutableSet());
+
+        newTreeId =
+            MergeUtil.mergeWithConflicts(
+                revWalk,
+                objectInserter,
+                dc,
+                "PATCH SET",
+                basePatchSetCommit,
+                "EDIT",
+                revWalk.parseCommit(editCommitId),
+                mergeResults,
+                useDiff3);
+        objectInserter.flush();
+      }
+
+      RevCommit currentEditCommit = changeEdit.getEditCommit();
+      ObjectId newEditCommitId =
+          createCommit(
+              objectInserter,
+              basePatchSetCommit,
+              newTreeId,
+              currentEditCommit.getFullMessage(),
+              currentEditCommit.getAuthorIdent(),
+              new PersonIdent(currentEditCommit.getCommitterIdent(), timestamp));
+
+      CodeReviewCommit newEditCommit = revWalk.parseCommit(newEditCommitId);
+      newEditCommit.setFilesWithGitConflicts(filesWithGitConflicts);
+      return newEditCommit;
+    }
+  }
+
+  private static ObjectId mergeTrees(Repository repository, ChangeEdit changeEdit, ObjectId treeId)
+      throws IOException, MergeConflictException {
+    PatchSet basePatchSet = changeEdit.getBasePatchSet();
+    ObjectId basePatchSetCommitId = basePatchSet.commitId();
+    ObjectId editCommitId = changeEdit.getEditCommit();
+
+    ThreeWayMerger merger = MergeStrategy.RESOLVE.newMerger(repository, true);
+    merger.setBase(basePatchSetCommitId);
+    boolean successful = merger.merge(treeId, editCommitId);
 
     if (!successful) {
       throw new MergeConflictException(
-          "The existing change edit could not be merged with another tree.");
+          "Rebasing change edit onto another patchset results in merge conflicts. Download the edit"
+              + " patchset and rebase manually to preserve changes.");
     }
-    return threeWayMerger.getResultTreeId();
+    return merger.getResultTreeId();
   }
 
   private String createNewCommitMessage(
@@ -605,18 +704,30 @@
       PersonIdent committer)
       throws IOException {
     try (ObjectInserter objectInserter = repository.newObjectInserter()) {
-      CommitBuilder builder = new CommitBuilder();
-      builder.setTreeId(tree);
-      builder.setParentIds(basePatchsetCommit.getParents());
-      builder.setAuthor(author);
-      builder.setCommitter(committer);
-      builder.setMessage(commitMessage);
-      ObjectId newCommitId = objectInserter.insert(builder);
-      objectInserter.flush();
-      return newCommitId;
+      return createCommit(
+          objectInserter, basePatchsetCommit, tree, commitMessage, author, committer);
     }
   }
 
+  private ObjectId createCommit(
+      ObjectInserter objectInserter,
+      RevCommit basePatchsetCommit,
+      ObjectId tree,
+      String commitMessage,
+      PersonIdent author,
+      PersonIdent committer)
+      throws IOException {
+    CommitBuilder builder = new CommitBuilder();
+    builder.setTreeId(tree);
+    builder.setParentIds(basePatchsetCommit.getParents());
+    builder.setAuthor(author);
+    builder.setCommitter(committer);
+    builder.setMessage(commitMessage);
+    ObjectId newCommitId = objectInserter.insert(builder);
+    objectInserter.flush();
+    return newCommitId;
+  }
+
   private PersonIdent getCommitterIdent(RevCommit basePatchsetCommit, Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
     return Optional.ofNullable(basePatchsetCommit.getCommitterIdent())
@@ -689,7 +800,7 @@
       if (ObjectId.isEqual(changeEdit.getEditCommit(), commitToModify)) {
         return newTreeId;
       }
-      return merge(repository, changeEdit, newTreeId);
+      return mergeTrees(repository, changeEdit, newTreeId);
     }
 
     @Override
@@ -715,9 +826,10 @@
         throws MergeConflictException {
       MergeAlgorithm mergeAlgorithm =
           new MergeAlgorithm(DiffAlgorithm.getAlgorithm(SupportedAlgorithm.MYERS));
-      RawText baseMessage = new RawText(commitToModify.getFullMessage().getBytes(Charsets.UTF_8));
-      RawText oldMessage = new RawText(editCommitMessage.getBytes(Charsets.UTF_8));
-      RawText newMessage = new RawText(newCommitMessage.getBytes(Charsets.UTF_8));
+      RawText baseMessage =
+          new RawText(commitToModify.getFullMessage().getBytes(StandardCharsets.UTF_8));
+      RawText oldMessage = new RawText(editCommitMessage.getBytes(StandardCharsets.UTF_8));
+      RawText newMessage = new RawText(newCommitMessage.getBytes(StandardCharsets.UTF_8));
       RawTextComparator textComparator = RawTextComparator.DEFAULT;
       MergeResult<RawText> mergeResult =
           mergeAlgorithm.merge(textComparator, baseMessage, oldMessage, newMessage);
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index dfc1ffb..ae80fe4 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -18,9 +18,11 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.UsedAt;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -39,26 +41,50 @@
 
   private final ObjectId baseTreeId;
   private final ImmutableList<? extends ObjectId> baseParents;
+  private final Optional<ObjectInserter> objectInserter;
+  private final Optional<ObjectReader> objectReader;
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public static TreeCreator basedOn(RevCommit baseCommit) {
     requireNonNull(baseCommit, "baseCommit is required");
-    return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
+    return new TreeCreator(
+        baseCommit.getTree(),
+        ImmutableList.copyOf(baseCommit.getParents()),
+        Optional.empty(),
+        Optional.empty());
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static TreeCreator basedOn(
+      RevCommit baseCommit, ObjectInserter objectInserter, ObjectReader objectReader) {
+    requireNonNull(baseCommit, "baseCommit is required");
+    return new TreeCreator(
+        baseCommit.getTree(),
+        ImmutableList.copyOf(baseCommit.getParents()),
+        Optional.of(objectInserter),
+        Optional.of(objectReader));
   }
 
   public static TreeCreator basedOnTree(
       ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
     requireNonNull(baseTreeId, "baseTreeId is required");
-    return new TreeCreator(baseTreeId, baseParents);
+    return new TreeCreator(baseTreeId, baseParents, Optional.empty(), Optional.empty());
   }
 
   public static TreeCreator basedOnEmptyTree() {
-    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
+    return new TreeCreator(
+        ObjectId.zeroId(), ImmutableList.of(), Optional.empty(), Optional.empty());
   }
 
-  private TreeCreator(ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+  private TreeCreator(
+      ObjectId baseTreeId,
+      ImmutableList<? extends ObjectId> baseParents,
+      Optional<ObjectInserter> objectInserter,
+      Optional<ObjectReader> objectReader) {
     this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
     this.baseParents = baseParents;
+    this.objectInserter = objectInserter;
+    this.objectReader = objectReader;
   }
 
   /**
@@ -121,14 +147,22 @@
   }
 
   private DirCache readBaseTree(Repository repository) throws IOException {
-    try (ObjectReader objectReader = repository.newObjectReader()) {
-      DirCache dirCache = DirCache.newInCore();
+    ObjectReader or = objectReader.orElseGet(() -> repository.newObjectReader());
+    try {
+      DirCache dirCache =
+          ObjectId.zeroId().equals(baseTreeId)
+              ? DirCache.newInCore()
+              : DirCache.read(or, baseTreeId);
       DirCacheBuilder dirCacheBuilder = dirCache.builder();
       if (!ObjectId.zeroId().equals(baseTreeId)) {
-        dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+        dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, or, baseTreeId);
       }
       dirCacheBuilder.finish();
       return dirCache;
+    } finally {
+      if (objectReader.isEmpty()) {
+        or.close();
+      }
     }
   }
 
@@ -141,17 +175,22 @@
     return pathEdits;
   }
 
+  private ObjectId writeAndGetId(Repository repository, DirCache tree) throws IOException {
+    ObjectInserter oi = objectInserter.orElseGet(() -> repository.newObjectInserter());
+    try {
+      ObjectId treeId = tree.writeTree(oi);
+      oi.flush();
+      return treeId;
+    } finally {
+      if (objectInserter.isEmpty()) {
+        oi.close();
+      }
+    }
+  }
+
   private static void applyPathEdits(DirCache tree, List<DirCacheEditor.PathEdit> pathEdits) {
     DirCacheEditor dirCacheEditor = tree.editor();
     pathEdits.forEach(dirCacheEditor::add);
     dirCacheEditor.finish();
   }
-
-  private static ObjectId writeAndGetId(Repository repository, DirCache tree) throws IOException {
-    try (ObjectInserter objectInserter = repository.newObjectInserter()) {
-      ObjectId treeId = tree.writeTree(objectInserter);
-      objectInserter.flush();
-      return treeId;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 2827f59..ac69120 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -76,6 +76,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -358,33 +359,23 @@
 
   public void addPatchSets(
       RevWalk revWalk,
+      Config repoConfig,
       ChangeAttribute ca,
-      Collection<PatchSet> ps,
-      Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
-      LabelTypes labelTypes,
-      AccountAttributeLoader accountLoader) {
-    addPatchSets(revWalk, ca, ps, approvals, false, null, labelTypes, accountLoader);
-  }
-
-  public void addPatchSets(
-      RevWalk revWalk,
-      ChangeAttribute ca,
-      Collection<PatchSet> ps,
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
       boolean includeFiles,
-      Change change,
-      LabelTypes labelTypes,
+      ChangeData changeData,
       AccountAttributeLoader accountLoader) {
-    if (!ps.isEmpty()) {
-      ca.patchSets = new ArrayList<>(ps.size());
-      for (PatchSet p : ps) {
-        PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p, accountLoader);
+    if (!changeData.patchSets().isEmpty()) {
+      ca.patchSets = new ArrayList<>(changeData.patchSets().size());
+      for (PatchSet p : changeData.patchSets()) {
+        PatchSetAttribute psa =
+            asPatchSetAttribute(revWalk, repoConfig, changeData, p, accountLoader);
         if (approvals != null) {
-          addApprovals(psa, p.id(), approvals, labelTypes, accountLoader);
+          addApprovals(psa, p.id(), approvals, changeData.getLabelTypes(), accountLoader);
         }
         ca.patchSets.add(psa);
         if (includeFiles) {
-          addPatchSetFileNames(psa, change, p);
+          addPatchSetFileNames(psa, changeData.change(), p);
         }
       }
     }
@@ -441,13 +432,18 @@
     }
   }
 
-  public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
-    return asPatchSetAttribute(revWalk, change, patchSet, null);
+  public PatchSetAttribute asPatchSetAttribute(
+      RevWalk revWalk, Config repoConfig, ChangeData changeData, PatchSet patchSet) {
+    return asPatchSetAttribute(revWalk, repoConfig, changeData, patchSet, null);
   }
 
   /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */
   public PatchSetAttribute asPatchSetAttribute(
-      RevWalk revWalk, Change change, PatchSet patchSet, AccountAttributeLoader accountLoader) {
+      RevWalk revWalk,
+      Config repoConfig,
+      ChangeData changeData,
+      PatchSet patchSet,
+      AccountAttributeLoader accountLoader) {
     PatchSetAttribute p = new PatchSetAttribute();
     p.revision = patchSet.commitId().name();
     p.number = patchSet.number();
@@ -474,12 +470,12 @@
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+              changeData.project(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
       }
-      p.kind = changeKindCache.getChangeKind(change, patchSet);
+      p.kind = changeKindCache.getChangeKind(revWalk, repoConfig, changeData, patchSet);
     } catch (IOException | StorageException e) {
       logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
     } catch (DiffNotAvailableException e) {
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 89aebde..66e894c 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -149,6 +150,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final boolean enableDraftCommentEvents;
+  private final ChangeData.Factory changeDataFactory;
 
   private final String gerritInstanceId;
 
@@ -161,7 +163,8 @@
       PatchSetUtil psUtil,
       ChangeNotes.Factory changeNotesFactory,
       @GerritServerConfig Config config,
-      @Nullable @GerritInstanceId String gerritInstanceId) {
+      @Nullable @GerritInstanceId String gerritInstanceId,
+      ChangeData.Factory changeDataFactory) {
     this.dispatcher = dispatcher;
     this.eventFactory = eventFactory;
     this.projectCache = projectCache;
@@ -171,6 +174,7 @@
     this.enableDraftCommentEvents =
         config.getBoolean("event", "stream-events", "enableDraftCommentEvents", false);
     this.gerritInstanceId = gerritInstanceId;
+    this.changeDataFactory = changeDataFactory;
   }
 
   private ChangeNotes getNotes(ChangeInfo info) {
@@ -206,12 +210,13 @@
   }
 
   private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
-      final Change change, PatchSet patchSet) {
+      final ChangeData changeData, PatchSet patchSet) {
     return Suppliers.memoize(
         () -> {
-          try (Repository repo = repoManager.openRepository(change.getProject());
+          try (Repository repo = repoManager.openRepository(changeData.change().getProject());
               RevWalk revWalk = new RevWalk(repo)) {
-            return eventFactory.asPatchSetAttribute(revWalk, change, patchSet);
+            return eventFactory.asPatchSetAttribute(
+                revWalk, repo.getConfig(), changeData, patchSet);
           } catch (IOException e) {
             throw new RuntimeException(e);
           }
@@ -301,7 +306,7 @@
       PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), patchSet);
       event.uploader = accountAttributeSupplier(ev.getWho());
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -317,7 +322,8 @@
       Change change = notes.getChange();
       ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
       event.remover = accountAttributeSupplier(ev.getWho());
       event.comment = ev.getComment();
@@ -338,7 +344,8 @@
       ReviewerAddedEvent event = new ReviewerAddedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.adder = accountAttributeSupplier(ev.getWho());
       for (AccountInfo reviewer : ev.getReviewers()) {
         event.reviewer = accountAttributeSupplier(reviewer);
@@ -466,7 +473,7 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.author = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, ps);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), ps);
       event.comment = ev.getComment();
       event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
 
@@ -485,7 +492,8 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.restorer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.reason = ev.getReason();
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -503,7 +511,8 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.submitter = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.newRev = ev.getNewRevisionId();
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -521,7 +530,8 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.abandoner = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.reason = ev.getReason();
 
       dispatcher.run(d -> d.postEvent(change, event));
@@ -540,7 +550,7 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), patchSet);
 
       dispatcher.run(d -> d.postEvent(change, event));
     } catch (StorageException e) {
@@ -558,7 +568,7 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.changer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+      event.patchSet = patchSetAttributeSupplier(changeDataFactory.create(notes), patchSet);
 
       dispatcher.run(d -> d.postEvent(change, event));
     } catch (StorageException e) {
@@ -574,7 +584,8 @@
       VoteDeletedEvent event = new VoteDeletedEvent(change);
 
       event.change = changeAttributeSupplier(change, notes);
-      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(notes));
+      event.patchSet =
+          patchSetAttributeSupplier(changeDataFactory.create(notes), psUtil.current(notes));
       event.comment = ev.getMessage();
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
       event.remover = accountAttributeSupplier(ev.getWho());
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 61e3819..cd91745 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -57,9 +57,6 @@
   public static String GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE =
       "GerritBackendFeature__always_reject_implicit_merges_on_merge";
 
-  /** Whether the rebase submit strategies should rebase merge commits. */
-  public static final String REBASE_MERGE_COMMITS = "GerritBackendFeature__rebase_merge_commits";
-
   /** Whether we allow fix suggestions in HumanComments. */
   public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
       "GerritBackendFeature__allow_fix_suggestions_in_comments";
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 7c8777f..c9b9f7a 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -96,7 +96,12 @@
   public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
     ChangeData cd = changeDataFactory.create(project, ps.id().changeId());
-    return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
+    return revisionInfo(cd, ps);
+  }
+
+  public RevisionInfo revisionInfo(ChangeData changeData, PatchSet ps)
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+    return revisionJsonFactory.create(changeOptions).getRevisionInfo(changeData, ps);
   }
 
   @Nullable
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index a60d982..f6d5881 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -78,7 +78,7 @@
       Event event =
           new Event(
               util.changeInfo(changeData),
-              util.revisionInfo(changeData.project(), patchSet),
+              util.revisionInfo(changeData, patchSet),
               util.accountInfo(uploader),
               when,
               notify.handling());
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index c471245..d841054 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -258,6 +258,20 @@
       } else {
         contentProcessor.processToEndOfLine(append);
         contentProcessor.processMultiline(toLine, append);
+        if (contentProcessor.endOfSource
+            && toLine == contentProcessor.srcPosition.line + 1
+            && toColumn == 0) {
+          // There is a special case, when the file doesn't have EOL mark at the end.
+          // The end of a fix replacement range can be expressed in 2 ways:
+          // 1) Line number = the last line number, column = the length of the last line (i.e. a
+          // character after the end of the string)
+          // Or
+          // 2) Line number = the last line number + 1, column = 0  (so it points to a non-existing
+          // line after the last line).
+          // This should be treated as a valid case, but processLineToColumn shouldn't be called
+          // (otherwise it throws an exceptions).
+          return;
+        }
         contentProcessor.processLineToColumn(toColumn, append);
       }
     }
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index ed16006..17cc5a0 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -286,6 +286,7 @@
       int size = 0;
       size += JavaWeights.OBJECT; // change
       size += JavaWeights.REFERENCE + GerritWeights.KEY_INT; // changeId
+      size += JavaWeights.REFERENCE + (c.getServerId() == null ? 0 : c.getServerId().length());
       size += JavaWeights.REFERENCE + JavaWeights.OBJECT + 40; // changeKey;
       size += JavaWeights.REFERENCE + GerritWeights.TIMESTAMP; // createdOn;
       size += JavaWeights.REFERENCE + GerritWeights.TIMESTAMP; // lastUpdatedOn;
@@ -300,15 +301,17 @@
       size += JavaWeights.REFERENCE + (c.getTopic() == null ? 0 : c.getTopic().length());
       size +=
           JavaWeights.REFERENCE
-              + (c.getOriginalSubject().equals(c.getSubject()) ? 0 : c.getSubject().length());
+              + (c.getOriginalSubject().equals(c.getSubject())
+                  ? 0
+                  : c.getOriginalSubject().length());
       size +=
           JavaWeights.REFERENCE + (c.getSubmissionId() == null ? 0 : c.getSubmissionId().length());
-      size += JavaWeights.REFERENCE + GerritWeights.ACCOUNT_ID; // assignee;
       size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // isPrivate;
       size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // workInProgress;
       size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // reviewStarted;
-      size += JavaWeights.REFERENCE + GerritWeights.CHANGE_NUM; // revertOf;
-      size += JavaWeights.REFERENCE + GerritWeights.PACTCH_SET_ID; // cherryPickOf;
+      size += JavaWeights.REFERENCE + (c.getRevertOf() == null ? 0 : GerritWeights.CHANGE_NUM);
+      size +=
+          JavaWeights.REFERENCE + (c.getCherryPickOf() == null ? 0 : GerritWeights.PATCH_SET_ID);
       return size;
     }
 
@@ -342,7 +345,7 @@
     public static final int KEY_INT = JavaWeights.OBJECT + JavaWeights.INT; // IntKey
     public static final int CHANGE_NUM = KEY_INT;
     public static final int ACCOUNT_ID = KEY_INT;
-    public static final int PACTCH_SET_ID =
+    public static final int PATCH_SET_ID =
         JavaWeights.OBJECT
             + (JavaWeights.REFERENCE + GerritWeights.CHANGE_NUM) // PatchSet.Id.changeId
             + JavaWeights.INT; // PatchSet.Id patch_num;
diff --git a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index 4f6094e..58c3eb1 100644
--- a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -54,7 +54,7 @@
         urlFormatter
             .get()
             .getChangeViewUrl(c.getProject(), c.getId())
-            .orElse(c.getId().toString()));
+            .orElseGet(() -> c.getId().toString()));
   }
 
   protected String cropSubject(String subject) {
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 1adbb67..86893c1 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -225,6 +225,34 @@
       boolean allowConflicts)
       throws IOException, MergeIdenticalTreeException, MergeConflictException,
           MethodNotAllowedException, InvalidMergeStrategyException {
+    return createCherryPickFromCommit(
+        inserter,
+        repoConfig,
+        mergeTip,
+        originalCommit,
+        cherryPickCommitterIdent,
+        commitMsg,
+        rw,
+        parentIndex,
+        ignoreIdenticalTree,
+        allowConflicts,
+        false);
+  }
+
+  public CodeReviewCommit createCherryPickFromCommit(
+      ObjectInserter inserter,
+      Config repoConfig,
+      RevCommit mergeTip,
+      RevCommit originalCommit,
+      PersonIdent cherryPickCommitterIdent,
+      String commitMsg,
+      CodeReviewRevWalk rw,
+      int parentIndex,
+      boolean ignoreIdenticalTree,
+      boolean allowConflicts,
+      boolean diff3Format)
+      throws IOException, MergeIdenticalTreeException, MergeConflictException,
+          MethodNotAllowedException, InvalidMergeStrategyException {
 
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     m.setBase(originalCommit.getParent(parentIndex));
@@ -300,7 +328,15 @@
 
       tree =
           mergeWithConflicts(
-              rw, inserter, dc, "HEAD", mergeTip, "CHANGE", originalCommit, mergeResults);
+              rw,
+              inserter,
+              dc,
+              "HEAD",
+              mergeTip,
+              "CHANGE",
+              originalCommit,
+              mergeResults,
+              diff3Format);
       logger.atFine().log(
           "AutoMerge treeId=%s (with conflicts, inserter: %s)", tree.name(), inserter);
     }
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index c977a34..a3c8deb 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -41,6 +41,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -196,7 +197,8 @@
                               ctx.getProject(),
                               psId.changeId(),
                               emailFactories.createMergedChangeEmail(
-                                  /* stickyApprovalDiff= */ Optional.empty()));
+                                  /* stickyApprovalDiff= */ Optional.empty(),
+                                  /* modifiedFiles= */ List.of()));
                       changeEmail.setPatchSet(patchSet, info);
                       OutgoingEmail outgoingEmail =
                           emailFactories.createOutgoingEmail(CHANGE_MERGED, changeEmail);
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 83024e3..d8afbbb 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -155,13 +155,14 @@
                 .byProject(key);
         Map<Change.Id, CachedChange> result = new HashMap<>(cds.size());
         for (ChangeData cd : cds) {
-          if (result.containsKey(cd.getId())) {
+          final Change.Id cdUniqueId = cd.virtualId();
+          if (result.containsKey(cdUniqueId)) {
             logger.atWarning().log(
                 "Duplicate changes returned from change query by project %s: %s, %s",
-                key, cd.change(), result.get(cd.getId()).change());
+                key, cd.change(), result.get(cdUniqueId).change());
           }
           result.put(
-              cd.getId(),
+              cdUniqueId,
               new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
         return List.copyOf(result.values());
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index d51ee5e..97b5abe 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -39,15 +39,20 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Delayed;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.FutureTask;
+import java.util.concurrent.PriorityBlockingQueue;
 import java.util.concurrent.RunnableScheduledFuture;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
@@ -56,7 +61,9 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.commons.lang3.mutable.MutableBoolean;
 import org.eclipse.jgit.lib.Config;
 
 /** Delayed execution of tasks using a background thread pool. */
@@ -88,6 +95,54 @@
     void onStop(Task<?> task);
   }
 
+  /**
+   * Register a TaskParker from a plugin like this:
+   *
+   * <p>bind(TaskListener.class).annotatedWith(Exports.named("MyParker")).to(MyParker.class);
+   */
+  public interface TaskParker extends TaskListener {
+    class NoOp extends TaskListener.NoOp implements TaskParker {
+      @Override
+      public boolean isReadyToStart(Task<?> task) {
+        return true;
+      }
+
+      @Override
+      public void onNotReadyToStart(Task<?> task) {}
+    }
+
+    /**
+     * Determine whether a {@link Task} is ready to run or whether it should get parked.
+     *
+     * <p>Tasks that are not ready to run will get parked and will not run until all {@link
+     * TaskParker}s return {@code true} from this method for the {@link Task}. This method may be
+     * called more than once, but will always be followed by a call to {@link
+     * #onNotReadyToStart(Task)} before being called again.
+     *
+     * <p>Resources should be acquired in this method via non-blocking means to avoid delaying the
+     * executor from calling {@link #onNotReadyToStart(Task)} on other {@link TaskParker}s holding
+     * resources.
+     *
+     * @param task the {@link Task} being considered for starting/parking
+     * @return a boolean indicating if the given {@link Task} is ready to run ({@code true}) or
+     *     should be parked ({@code false})
+     */
+    boolean isReadyToStart(Task<?> task);
+
+    /**
+     * This method will be called after this {@link TaskParker} returns {@code true} from {@link
+     * #isReadyToStart(Task)} and another {@link TaskParker} returns {@code false}, thus preventing
+     * the start.
+     *
+     * <p>Implementors should use this method to free any resources acquired in {@link
+     * #isReadyToStart(Task)} based on the expectation that the task would start. Those resources
+     * can be re-acquired when {@link #isReadyToStart(Task)} is called again later.
+     *
+     * @param task the {@link Task} that was prevented from starting by another {@link TaskParker}
+     */
+    void onNotReadyToStart(Task<?> task);
+  }
+
   public static class Lifecycle implements LifecycleListener {
     private final WorkQueue workQueue;
 
@@ -288,9 +343,75 @@
 
   /** An isolated queue. */
   private class Executor extends ScheduledThreadPoolExecutor {
+    private class ParkedTask implements Comparable<ParkedTask> {
+      public final CancellableCountDownLatch latch = new CancellableCountDownLatch(1);
+      public final Task<?> task;
+      private final Long priority = priorityGenerator.getAndIncrement();
+
+      public ParkedTask(Task<?> task) {
+        this.task = task;
+      }
+
+      @Override
+      public int compareTo(ParkedTask o) {
+        return priority.compareTo(o.priority);
+      }
+
+      /**
+       * Cancel a parked {@link Task}.
+       *
+       * <p>Tasks awaiting in {@link #onStart(Task)} to be un-parked can be interrupted using this
+       * method.
+       */
+      public void cancel() {
+        latch.cancel();
+      }
+
+      public boolean isEqualTo(Task<?> task) {
+        return this.task.taskId == task.taskId;
+      }
+    }
+
+    private class CancellableCountDownLatch extends CountDownLatch {
+      protected volatile boolean cancelled = false;
+
+      public CancellableCountDownLatch(int count) {
+        super(count);
+      }
+
+      /**
+       * Unblocks threads which are waiting until the latch has counted down to zero.
+       *
+       * <p>If the current count is zero, then this method returns immediately.
+       *
+       * <p>If the current count is greater than zero, then it decrements until the count reaches
+       * zero and causes all threads waiting on the latch using {@link CountDownLatch#await()} to
+       * throw an {@link InterruptedException}.
+       */
+      public void cancel() {
+        if (getCount() == 0) {
+          return;
+        }
+        this.cancelled = true;
+        while (getCount() > 0) {
+          countDown();
+        }
+      }
+
+      @Override
+      public void await() throws InterruptedException {
+        super.await();
+        if (cancelled) {
+          throw new InterruptedException();
+        }
+      }
+    }
+
     private final ConcurrentHashMap<Integer, Task<?>> all;
     private final ConcurrentHashMap<Runnable, Long> nanosPeriodByRunnable;
     private final String queueName;
+    private final AtomicLong priorityGenerator = new AtomicLong();
+    private final PriorityBlockingQueue<ParkedTask> parked = new PriorityBlockingQueue<>();
 
     Executor(int corePoolSize, final String queueName) {
       super(
@@ -488,7 +609,17 @@
     }
 
     void remove(Task<?> task) {
-      all.remove(task.getTaskId(), task);
+      boolean isRemoved = all.remove(task.getTaskId(), task);
+      if (isRemoved && !listeners.isEmpty()) {
+        cancelIfParked(task);
+      }
+    }
+
+    void cancelIfParked(Task<?> task) {
+      Optional<ParkedTask> parkedTask = parked.stream().filter(p -> p.isEqualTo(task)).findFirst();
+      if (parkedTask.isPresent()) {
+        parkedTask.get().cancel();
+      }
     }
 
     Task<?> getTask(int id) {
@@ -503,12 +634,89 @@
       return all.values();
     }
 
+    public void waitUntilReadyToStart(Task<?> task) {
+      if (!listeners.isEmpty() && !isReadyToStart(task)) {
+        ParkedTask parkedTask = new ParkedTask(task);
+        parked.offer(parkedTask);
+        task.runningState.set(Task.State.PARKED);
+        incrementCorePoolSizeBy(1);
+        try {
+          parkedTask.latch.await();
+        } catch (InterruptedException e) {
+          logger.atSevere().withCause(e).log("Parked Task(%s) Interrupted", task);
+          parked.remove(parkedTask);
+        } finally {
+          incrementCorePoolSizeBy(-1);
+        }
+      }
+    }
+
     public void onStart(Task<?> task) {
-      listeners.runEach(extension -> extension.getProvider().get().onStart(task));
+      listeners.runEach(extension -> extension.get().onStart(task));
     }
 
     public void onStop(Task<?> task) {
-      listeners.runEach(extension -> extension.getProvider().get().onStop(task));
+      listeners.runEach(extension -> extension.get().onStop(task));
+      updateParked();
+    }
+
+    protected boolean isReadyToStart(Task<?> task) {
+      MutableBoolean isReady = new MutableBoolean(true);
+      Set<TaskParker> readyParkers = new HashSet<>();
+      listeners.runEach(
+          extension -> {
+            if (isReady.isTrue()) {
+              TaskListener listener = extension.get();
+              if (listener instanceof TaskParker) {
+                TaskParker parker = (TaskParker) listener;
+                if (parker.isReadyToStart(task)) {
+                  readyParkers.add(parker);
+                } else {
+                  isReady.setFalse();
+                }
+              }
+            }
+          });
+
+      if (isReady.isFalse()) {
+        listeners.runEach(
+            extension -> {
+              TaskListener listener = extension.get();
+              if (readyParkers.contains(listener)) {
+                ((TaskParker) listener).onNotReadyToStart(task);
+              }
+            });
+      }
+      return isReady.getValue();
+    }
+
+    public void updateParked() {
+      ParkedTask ready = parked.poll();
+      if (ready == null) {
+        return;
+      }
+      List<ParkedTask> notReady = new ArrayList<>();
+      while (ready != null && !isReadyToStart(ready.task)) {
+        // Do not add a cancelled task back into the parked queue
+        if (Task.State.PARKED.equals(ready.task.getState())) {
+          notReady.add(ready);
+        }
+        ready = parked.poll();
+      }
+      parked.addAll(notReady);
+
+      if (ready != null) {
+        ready.latch.countDown();
+      }
+    }
+
+    public synchronized void incrementCorePoolSizeBy(int i) {
+      super.setCorePoolSize(getCorePoolSize() + i);
+    }
+
+    @Override
+    public synchronized void setCorePoolSize(int s) {
+      super.setCorePoolSize(s);
     }
   }
 
@@ -556,13 +764,14 @@
       // Ordered like this so ordinal matches the order we would
       // prefer to see tasks sorted in: done before running,
       // stopping before running, running before starting,
-      // starting before ready, ready before sleeping.
+      // starting before parked, parked before ready, ready before sleeping.
       //
       DONE,
       CANCELLED,
       STOPPING,
       RUNNING,
       STARTING,
+      PARKED,
       READY,
       SLEEPING,
       OTHER
@@ -694,12 +903,14 @@
 
     @Override
     public void run() {
-      if (runningState.compareAndSet(null, State.STARTING)) {
+      if (runningState.compareAndSet(null, State.READY)) {
         String oldThreadName = Thread.currentThread().getName();
         try {
+          Thread.currentThread().setName(oldThreadName + "[" + this + "]");
+          executor.waitUntilReadyToStart(this); // Transitions to PARKED while not ready to start
+          runningState.set(State.STARTING);
           executor.onStart(this);
           runningState.set(State.RUNNING);
-          Thread.currentThread().setName(oldThreadName + "[" + this + "]");
           task.run();
         } finally {
           Thread.currentThread().setName(oldThreadName);
diff --git a/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java b/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java
new file mode 100644
index 0000000..de8dabd
--- /dev/null
+++ b/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.meta;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.RefNames;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Versioned configuration file living in git
+ *
+ * <p>This class is a low-level API that allows callers to read the config directly from a
+ * repository and make updates to it.
+ */
+public class VersionedConfigFile extends VersionedMetaData {
+  protected final String ref;
+  protected final String fileName;
+  protected final String defaultOnSaveMessage;
+  protected Config cfg;
+
+  public VersionedConfigFile(String fileName) {
+    this(RefNames.REFS_CONFIG, fileName);
+  }
+
+  public VersionedConfigFile(String ref, String fileName) {
+    this(ref, fileName, "Updated configuration\n");
+  }
+
+  public VersionedConfigFile(String ref, String fileName, String defaultOnSaveMessage) {
+    this.ref = ref;
+    this.fileName = fileName;
+    this.defaultOnSaveMessage = defaultOnSaveMessage;
+  }
+
+  public Config getConfig() {
+    if (cfg == null) {
+      cfg = new Config();
+    }
+    return cfg;
+  }
+
+  protected String getFileName() {
+    return fileName;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    cfg = readConfig(fileName);
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage(defaultOnSaveMessage);
+    }
+    saveConfig(fileName, cfg);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 4194275..b849719 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -26,6 +26,7 @@
         "//lib:args4j",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 69dae04..7544f95 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 import static com.google.gerrit.entities.RefNames.isConfigRef;
 import static com.google.gerrit.entities.RefNames.isRefsUsersSelf;
@@ -38,6 +37,7 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
@@ -107,11 +107,13 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.AclInfoController;
 import com.google.gerrit.server.CancellationMetrics;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DeadlineChecker;
 import com.google.gerrit.server.DraftCommentsReader;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InvalidDeadlineException;
 import com.google.gerrit.server.PatchSetUtil;
@@ -121,6 +123,8 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.cancellation.RequestStateContext;
@@ -145,6 +149,7 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.receive.RejectionReason.MetricBucket;
 import com.google.gerrit.server.git.validators.CommentCountValidator;
 import com.google.gerrit.server.git.validators.CommentSizeValidator;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -184,6 +189,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
@@ -229,6 +235,7 @@
 import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
+import java.util.logging.Level;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
@@ -332,6 +339,7 @@
   private static class Metrics {
     private final Counter0 psRevisionMissing;
     private final Counter3<String, String, String> pushCount;
+    private final Counter3<String, String, Integer> rejectCount;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
@@ -356,6 +364,19 @@
                       "The type of the update (CREATE, UPDATE, CREATE/UPDATE,"
                           + " UPDATE_NONFASTFORWARD, DELETE).")
                   .build());
+      rejectCount =
+          metricMaker.newCounter(
+              "receivecommits/reject_count",
+              new Description("number of rejected pushes"),
+              Field.ofString("kind", (metadataBuilder, fieldValue) -> {})
+                  .description("The push kind (direct vs. magic).")
+                  .build(),
+              Field.ofString("reason", (metadataBuilder, fieldValue) -> {})
+                  .description("The rejection reason.")
+                  .build(),
+              Field.ofInteger("status", (metadataBuilder, fieldValue) -> {})
+                  .description("The HTTP status code.")
+                  .build());
     }
   }
 
@@ -365,10 +386,13 @@
 
   // Injected fields.
   private final AccountResolver accountResolver;
+  private final AclInfoController aclInfoController;
   private final AllProjectsName allProjectsName;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final CancellationMetrics cancellationMetrics;
   private final ChangeEditUtil editUtil;
+  private final PluginSetContext<ExceptionHook> exceptionHooks;
   private final ChangeIndexer indexer;
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeNotes.Factory notesFactory;
@@ -393,7 +417,6 @@
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
-  private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final ReceiveConfig receiveConfig;
@@ -406,6 +429,7 @@
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
   private final SetTopicOp.Factory setTopicFactory;
+  private final ServiceUserClassifier serviceUserClassifier;
   private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
@@ -431,6 +455,8 @@
   /** Multimap of error text to refnames that produced that error. */
   private final ListMultimap<String, String> errors;
 
+  private final LinkedHashMap<ReceiveCommand, RejectionReason> rejectionReasons;
+
   private final ListMultimap<String, String> pushOptions;
   private final ReceivePackRefCache receivePackRefCache;
   private final Map<Change.Id, ReplaceRequest> replaceByChange;
@@ -453,12 +479,15 @@
   @Inject
   ReceiveCommits(
       AccountResolver accountResolver,
+      AclInfoController aclInfoController,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       CancellationMetrics cancellationMetrics,
       ProjectConfig.Factory projectConfigFactory,
       @GerritServerConfig Config config,
       ChangeEditUtil editUtil,
+      PluginSetContext<ExceptionHook> exceptionHooks,
       ChangeIndexer indexer,
       ChangeInserter.Factory changeInserterFactory,
       ChangeNotes.Factory notesFactory,
@@ -482,7 +511,6 @@
       DynamicSet<PerformanceLogger> performanceLoggers,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      Provider<InternalChangeQuery> queryProvider,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeOpRepoManager> ormProvider,
       PublishCommentsOp.Factory publishCommentsOp,
@@ -495,6 +523,7 @@
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
       SetTopicOp.Factory setTopicFactory,
+      ServiceUserClassifier serviceUserClassifier,
       @SuperprojectUpdateOnSubmission
           ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       TagCache tagCache,
@@ -510,8 +539,10 @@
       throws IOException {
     // Injected fields.
     this.accountResolver = accountResolver;
+    this.aclInfoController = aclInfoController;
     this.allProjectsName = allProjectsName;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.cancellationMetrics = cancellationMetrics;
     this.changeFormatter = changeFormatterProvider.get();
     this.changeUtil = changeUtil;
@@ -525,8 +556,10 @@
     this.deadlineCheckerFactory = deadlineCheckerFactory;
     this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
     this.editUtil = editUtil;
+    this.exceptionHooks = exceptionHooks;
     this.hashtagsFactory = hashtagsFactory;
     this.setTopicFactory = setTopicFactory;
+    this.serviceUserClassifier = serviceUserClassifier;
     this.indexer = indexer;
     this.initializers = initializers;
     this.mergeOpProvider = mergeOpProvider;
@@ -543,7 +576,6 @@
     this.psUtil = psUtil;
     this.performanceLoggers = performanceLoggers;
     this.publishCommentsOp = publishCommentsOp;
-    this.queryProvider = queryProvider;
     this.receiveConfig = receiveConfig;
     this.refValidatorsFactory = refValidatorsFactory;
     this.replaceOpFactory = replaceOpFactory;
@@ -574,6 +606,7 @@
 
     // Collections populated during processing.
     errors = MultimapBuilder.linkedHashKeys().arrayListValues().build();
+    rejectionReasons = new LinkedHashMap<>();
     messages = new ConcurrentLinkedQueue<>();
     pushOptions = LinkedListMultimap.create();
     replaceByChange = new LinkedHashMap<>();
@@ -678,6 +711,8 @@
       requestListeners.runEach(l -> l.onRequest(requestInfo));
       traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get()));
 
+      aclInfoController.enableAclLoggingIfUserCanViewAccess(traceContext);
+
       // Log the push options here, rather than in parsePushOptions(), so that they are included
       // into the trace if tracing is enabled.
       logger.atFine().log("push options: %s", receivePack.getPushOptions());
@@ -692,9 +727,12 @@
               .addRequestStateProvider(
                   deadlineCheckerFactory.create(start, requestInfo, clientProvidedDeadlineValue))) {
         processCommandsUnsafe(commands, progress);
-        rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+        rejectRemaining(
+            commands,
+            RejectionReason.create(MetricBucket.INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR));
       } catch (InvalidDeadlineException e) {
-        rejectRemaining(commands, e.getMessage());
+        rejectRemaining(
+            commands, RejectionReason.create(MetricBucket.INVALID_DEADLINE, e.getMessage()));
       } catch (RuntimeException e) {
         Optional<RequestCancelledException> requestCancelledException =
             RequestCancelledException.getFromCausalChain(e);
@@ -710,7 +748,21 @@
               String.format(
                   " (%s)", requestCancelledException.get().getCancellationMessage().get()));
         }
-        rejectRemaining(commands, msg.toString());
+
+        MetricBucket metricBucket = MetricBucket.INTERNAL_SERVER_ERROR;
+        switch (requestCancelledException.get().getCancellationReason()) {
+          case CLIENT_CLOSED_REQUEST:
+            metricBucket = MetricBucket.CLIENT_CLOSED_REQUEST;
+            break;
+          case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
+            metricBucket = MetricBucket.CLIENT_PROVIDED_DEADLINE_EXCEEDED;
+            break;
+          case SERVER_DEADLINE_EXCEEDED:
+            metricBucket = MetricBucket.SERVER_DEADLINE_EXCEEDED;
+            break;
+        }
+
+        rejectRemaining(commands, RejectionReason.create(metricBucket, msg.toString()));
       }
 
       // This sends error messages before the 'done' string of the progress monitor is sent.
@@ -718,25 +770,68 @@
       // successfully.
       sendErrorMessages();
 
+      // If there was a permission issue send ACL infos to the client (if ACL logging was turned
+      // on).
+      if (rejectionReasons.values().stream()
+          .map(RejectionReason::statusCode)
+          .anyMatch(statusCode -> statusCode == 403)) {
+        aclInfoController.getAclInfoMessage().ifPresent(this::addMessage);
+      }
+
       commandProgress.end();
       loggingTags = traceContext.getTags();
       logger.atFine().log("Processing commands done.");
+    } catch (PermissionBackendException | RuntimeException e) {
+      String formattedCause = getFormattedCause(e).orElse(e.getClass().getSimpleName());
+      int statusCode =
+          getStatus(e).map(ExceptionHook.Status::statusCode).orElse(SC_INTERNAL_SERVER_ERROR);
+      logger.at(statusCode < SC_INTERNAL_SERVER_ERROR ? Level.INFO : Level.SEVERE).withCause(e).log(
+          "ReceiveCommits failed due to %s", formattedCause);
+      String pushKind = "magic or direct push";
+      if (serviceUserClassifier.isServiceUser(user.getAccountId())) {
+        pushKind += " by service user";
+      }
+      metrics.rejectCount.increment(pushKind, formattedCause, statusCode);
+
+      // Re-throw any RuntimeException as they are.
+      Throwables.throwIfUnchecked(e);
+
+      // Wrap any checked exception (e.g. PermissionBackendException) into a StorageException, which
+      // is an unchecked exception, as we cannot throw checked exceptions from this method.
+      throw new StorageException(e);
     }
     progress.end();
     return result.build();
   }
 
+  private Optional<String> getFormattedCause(Throwable t) {
+    return exceptionHooks.stream()
+        .map(h -> h.formatCause(t))
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .findFirst();
+  }
+
+  private Optional<ExceptionHook.Status> getStatus(Throwable err) {
+    return exceptionHooks.stream()
+        .map(h -> h.getStatus(err))
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .findFirst();
+  }
+
   // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     logger.atFine().log("Calling user: %s, commands: %d", user.getLoggableName(), commands.size());
 
-    // If the list of groups is large, the log entry may get dropped, so separate out.
-    logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
-
     if (!projectState.getProject().getState().permitsWrite()) {
       for (ReceiveCommand cmd : commands) {
-        reject(cmd, "prohibited by Gerrit: project state does not permit write");
+        reject(
+            cmd,
+            RejectionReason.create(
+                MetricBucket.PROJECT_NOT_WRITABLE,
+                "prohibited by Gerrit: project state does not permit write"));
       }
       return;
     }
@@ -758,7 +853,11 @@
       }
 
       if (!magicCommands.isEmpty() && !regularCommands.isEmpty()) {
-        rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
+        rejectRemaining(
+            commands,
+            RejectionReason.create(
+                MetricBucket.CANNOT_COMBINE_NORMAL_AND_MAGIC_PUSHES,
+                "cannot combine normal pushes and magic pushes"));
         return;
       }
 
@@ -791,7 +890,8 @@
           if (first) {
             first = false;
           } else {
-            reject(cmd, "duplicate request");
+            reject(
+                cmd, RejectionReason.create(MetricBucket.DUPLICATE_REQUEST, "duplicate request"));
           }
         }
       } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
@@ -827,7 +927,7 @@
 
       logger.atFine().log(
           "Command results: %s",
-          lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
+          commands.stream().map(ReceiveCommits::commandToString).collect(joining(",")));
     }
   }
 
@@ -884,7 +984,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(batchUpdates, false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
@@ -1080,10 +1180,15 @@
           }
         } catch (ResourceConflictException e) {
           addError(e.getMessage());
-          reject(magicBranchCmd, "conflict");
+          reject(magicBranchCmd, RejectionReason.create(MetricBucket.CONFLICT, "conflict"));
+        } catch (UnresolvableAccountException e) {
+          logger.atFine().log("Rejecting because account cannot be resolved: %s", e.getMessage());
+          reject(
+              magicBranchCmd,
+              RejectionReason.create(MetricBucket.ACCOUNT_NOT_FOUND, e.getMessage()));
         } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
           logger.atFine().withCause(e).log("Rejecting due to client error");
-          reject(magicBranchCmd, e.getMessage());
+          reject(magicBranchCmd, RejectionReason.create(MetricBucket.CLIENT_ERROR, e.getMessage()));
         } catch (RestApiException | IOException | UpdateException e) {
           throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
         }
@@ -1093,7 +1198,7 @@
             submit(newChanges, replaceByChange.values());
           } catch (ResourceConflictException e) {
             addError(e.getMessage());
-            reject(magicBranchCmd, "conflict");
+            reject(magicBranchCmd, RejectionReason.create(MetricBucket.CONFLICT, "conflict"));
           } catch (RestApiException
               | StorageException
               | UpdateException
@@ -1101,7 +1206,9 @@
               | ConfigInvalidException
               | PermissionBackendException e) {
             logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
-            reject(magicBranchCmd, "error during submit");
+            reject(
+                magicBranchCmd,
+                RejectionReason.create(MetricBucket.SUBMIT_ERROR, "error during submit"));
           }
         }
       }
@@ -1178,8 +1285,8 @@
         magicBranchCmd.setResult(OK);
       }
       for (ReplaceRequest replace : replaceByChange.values()) {
-        String rejectMessage = replace.getRejectMessage();
-        if (rejectMessage == null) {
+        Optional<RejectionReason> rejectionReason = replace.getRejectionReason();
+        if (!rejectionReason.isPresent()) {
           if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
             // Not necessarily the magic branch, so need to set OK on the original
             // value.
@@ -1187,7 +1294,7 @@
           }
         } else {
           logger.atFine().log("Rejecting due to message from ReplaceOp");
-          reject(replace.inputCommand, rejectMessage);
+          reject(replace.inputCommand, rejectionReason.get());
         }
       }
     }
@@ -1292,7 +1399,7 @@
       }
 
       if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
-        reject(cmd, "not valid ref");
+        reject(cmd, RejectionReason.create(MetricBucket.INVALID_REF, "not valid ref"));
         return;
       }
       if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
@@ -1310,14 +1417,20 @@
           // NoteDb refs.
           reject(
               cmd,
-              "NoteDb update requires -o "
-                  + NoteDbPushOption.OPTION_NAME
-                  + "="
-                  + NoteDbPushOption.ALLOW.value());
+              RejectionReason.create(
+                  MetricBucket.NOTEDB_UPDATE_WITHOUT_ALLOW_OPTION,
+                  "NoteDb update requires -o "
+                      + NoteDbPushOption.OPTION_NAME
+                      + "="
+                      + NoteDbPushOption.ALLOW.value()));
           return;
         }
         if (!permissionBackend.user(user).test(GlobalPermission.ACCESS_DATABASE)) {
-          reject(cmd, "NoteDb update requires access database permission");
+          reject(
+              cmd,
+              RejectionReason.create(
+                  MetricBucket.NOTEDB_UPDATE_WITHOUT_ACCESS_DATABASE_PERMISSION,
+                  "NoteDb update requires access database permission"));
           return;
         }
       }
@@ -1340,7 +1453,11 @@
           break;
 
         default:
-          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
+          reject(
+              cmd,
+              RejectionReason.create(
+                  MetricBucket.UNKNOWN_COMMAND_TYPE,
+                  "prohibited by Gerrit: unknown command type " + cmd.getType()));
           return;
       }
 
@@ -1362,9 +1479,11 @@
       if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
         reject(
             cmd,
-            String.format(
-                "must be either project owner or have %s permission",
-                ProjectPermission.WRITE_CONFIG.describeForException()));
+            RejectionReason.create(
+                MetricBucket.PROJECT_CONFIG_UPDATE_NOT_ALLOWED,
+                String.format(
+                    "must be either project owner or have %s permission",
+                    ProjectPermission.WRITE_CONFIG.describeForException())));
         return;
       }
 
@@ -1380,7 +1499,11 @@
               for (ValidationError err : cfg.getValidationErrors()) {
                 addError("  " + err.getMessage());
               }
-              reject(cmd, "invalid project configuration");
+              reject(
+                  cmd,
+                  RejectionReason.create(
+                      MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+                      "invalid project configuration"));
               logger.atSevere().log(
                   "User %s tried to push invalid project configuration %s for %s",
                   user.getLoggableName(), cmd.getNewId().name(), project.getName());
@@ -1391,7 +1514,11 @@
             if (oldParent == null) {
               // update of the 'All-Projects' project
               if (newParent != null) {
-                reject(cmd, "invalid project configuration: root project cannot have parent");
+                reject(
+                    cmd,
+                    RejectionReason.create(
+                        MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+                        "invalid project configuration: root project cannot have parent"));
                 return;
               }
             } else {
@@ -1402,25 +1529,40 @@
                       .project(project.getNameKey())
                       .test(ProjectPermission.WRITE_CONFIG)) {
                     reject(
-                        cmd, "invalid project configuration: only project owners can set parent");
+                        cmd,
+                        RejectionReason.create(
+                            MetricBucket.PROJECT_CONFIG_UPDATE_NOT_ALLOWED,
+                            "invalid project configuration: only project owners can set parent"));
                     return;
                   }
                 } else {
                   if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
-                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+                    reject(
+                        cmd,
+                        RejectionReason.create(
+                            MetricBucket.PROJECT_CONFIG_UPDATE_NOT_ALLOWED,
+                            "invalid project configuration: only Gerrit admin can set parent"));
                     return;
                   }
                 }
               }
 
               if (!projectCache.get(newParent).isPresent()) {
-                reject(cmd, "invalid project configuration: parent does not exist");
+                reject(
+                    cmd,
+                    RejectionReason.create(
+                        MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+                        "invalid project configuration: parent does not exist"));
                 return;
               }
             }
             validatePluginConfig(cmd, cfg);
           } catch (Exception e) {
-            reject(cmd, "invalid project configuration");
+            reject(
+                cmd,
+                RejectionReason.create(
+                    MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+                    "invalid project configuration"));
             logger.atSevere().withCause(e).log(
                 "User %s tried to push invalid project configuration %s for %s",
                 user.getLoggableName(), cmd.getNewId().name(), project.getName());
@@ -1434,8 +1576,10 @@
         default:
           reject(
               cmd,
-              "prohibited by Gerrit: don't know how to handle config update of type "
-                  + cmd.getType());
+              RejectionReason.create(
+                  MetricBucket.UNKNOWN_COMMAND_TYPE,
+                  "prohibited by Gerrit: don't know how to handle config update of type "
+                      + cmd.getType()));
       }
     }
   }
@@ -1464,10 +1608,12 @@
           && !configEntry.isEditable(projectState)) {
         reject(
             cmd,
-            String.format(
-                "invalid project configuration: Not allowed to set parameter"
-                    + " '%s' of plugin '%s' on project '%s'.",
-                e.getExportName(), e.getPluginName(), project.getName()));
+            RejectionReason.create(
+                MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+                String.format(
+                    "invalid project configuration: Not allowed to set parameter"
+                        + " '%s' of plugin '%s' on project '%s'.",
+                    e.getExportName(), e.getPluginName(), project.getName())));
         continue;
       }
 
@@ -1476,10 +1622,12 @@
           && !configEntry.getPermittedValues().contains(value)) {
         reject(
             cmd,
-            String.format(
-                "invalid project configuration: The value '%s' is "
-                    + "not permitted for parameter '%s' of plugin '%s'.",
-                value, e.getExportName(), e.getPluginName()));
+            RejectionReason.create(
+                MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+                String.format(
+                    "invalid project configuration: The value '%s' is "
+                        + "not permitted for parameter '%s' of plugin '%s'.",
+                    value, e.getExportName(), e.getPluginName())));
       }
     }
   }
@@ -1490,7 +1638,10 @@
       if (repo.resolve(cmd.getRefName()) != null) {
         reject(
             cmd,
-            String.format("Cannot create ref '%s' because it already exists.", cmd.getRefName()));
+            RejectionReason.create(
+                MetricBucket.CANNOT_CREATE_REF_BECAUSE_IT_ALREADY_EXISTS,
+                String.format(
+                    "Cannot create ref '%s' because it already exists.", cmd.getRefName())));
         return;
       }
       RevObject obj;
@@ -1518,7 +1669,10 @@
         rejectProhibited(cmd, denied);
         return;
       } catch (ResourceConflictException denied) {
-        reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
+        reject(
+            cmd,
+            RejectionReason.create(
+                MetricBucket.CONFLICT, "prohibited by Gerrit: " + denied.getMessage()));
         return;
       }
 
@@ -1536,7 +1690,8 @@
       Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
       if (!err.isPresent()) {
         if (isHead(cmd) && !isCommit(globalRevWalk, cmd)) {
-          reject(cmd, "head must point to commit");
+          reject(
+              cmd, RejectionReason.create(MetricBucket.INVALID_HEAD, "head must point to commit"));
           return;
         }
         if (validRefOperation(cmd)) {
@@ -1566,7 +1721,7 @@
     if (obj instanceof RevCommit) {
       return true;
     }
-    reject(cmd, "not a commit");
+    reject(cmd, RejectionReason.create(MetricBucket.NOT_A_COMMIT, "not a commit"));
     return false;
   }
 
@@ -1575,10 +1730,16 @@
       logger.atFine().log("Deleting %s", cmd);
       if (cmd.getRefName().startsWith(REFS_CHANGES)) {
         errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
-        reject(cmd, "cannot delete changes");
+        reject(
+            cmd,
+            RejectionReason.create(MetricBucket.CANNOT_DELETE_CHANGES, "cannot delete changes"));
       } else if (isConfigRef(cmd.getRefName())) {
         errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
-        reject(cmd, "cannot delete project configuration");
+        reject(
+            cmd,
+            RejectionReason.create(
+                MetricBucket.CANNOT_DELETE_PROJECT_CONFIGURATION,
+                "cannot delete project configuration"));
       }
 
       Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
@@ -1588,6 +1749,19 @@
       } else {
         rejectProhibited(cmd, err.get());
       }
+      if (ObjectId.zeroId().equals(cmd.getOldId())) {
+        // Git CLI sends DELETE 0..0 0...0 when the server doesn't send the deleted ref during
+        // negotiation. The server usually doesn't send it when ref doesn't exist or when it
+        // is not visible to a caller - so the message that the ref doesn't exist should be ok
+        // here.
+        // Without this check, such delete always fails with the "internal error" message, caused
+        // by the checkArgument in the  ChainedReceiveCommands#add.
+        reject(
+            cmd,
+            RejectionReason.create(
+                MetricBucket.REF_NOT_FOUND,
+                String.format("The ref %s doesn't exist", cmd.getRefName())));
+      }
     }
   }
 
@@ -1640,18 +1814,18 @@
     reject(cmd, prohibited(err, cmd.getRefName()));
   }
 
-  private static String prohibited(AuthException e, String alreadyDisplayedResource) {
-    String msg = e.getMessage();
+  private static RejectionReason prohibited(AuthException e, String alreadyDisplayedResource) {
     if (e instanceof PermissionDeniedException) {
       PermissionDeniedException pde = (PermissionDeniedException) e;
       if (pde.getResource().isPresent()
           && pde.getResource().get().equals(alreadyDisplayedResource)) {
         // Avoid repeating resource name if exactly the given name was already displayed by the
         // generic git push machinery.
-        msg = PermissionDeniedException.MESSAGE_PREFIX + pde.describePermission();
+        return RejectionReason.create(pde);
       }
     }
-    return "prohibited by Gerrit: " + msg;
+    return RejectionReason.create(
+        MetricBucket.PROHIBITED, "prohibited by Gerrit: " + e.getMessage());
   }
 
   static class MagicBranchInput {
@@ -2001,7 +2175,7 @@
       } catch (CmdLineException e) {
         if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
           logger.atFine().log("Invalid branch syntax");
-          reject(cmd, e.getMessage());
+          reject(cmd, RejectionReason.create(MetricBucket.INVALID_BRANCH_SYNTAX, e.getMessage()));
           return;
         }
         ref = null; // never happens
@@ -2010,14 +2184,20 @@
       if (magicBranch.skipValidation) {
         reject(
             cmd,
-            String.format(
-                "\"--%s\" option is only supported for direct push", PUSH_OPTION_SKIP_VALIDATION));
+            RejectionReason.create(
+                MetricBucket.CANNOT_SKIP_VALIDATION_FOR_MAGIC_PUSH,
+                String.format(
+                    "\"--%s\" option is only supported for direct push",
+                    PUSH_OPTION_SKIP_VALIDATION)));
         return;
       }
 
       if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
         reject(
-            cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
+            cmd,
+            RejectionReason.create(
+                MetricBucket.TOPIC_TOO_LARGE,
+                String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH)));
       }
 
       if (magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
@@ -2039,7 +2219,7 @@
         }
 
         addMessage(w.toString());
-        reject(cmd, "see help");
+        reject(cmd, RejectionReason.create(MetricBucket.HELP_REQUESTED, "see help"));
         return;
       }
       if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
@@ -2057,9 +2237,11 @@
         logger.atFine().log("Ref %s not found", ref);
         if (ref.startsWith(Constants.R_HEADS)) {
           String n = ref.substring(Constants.R_HEADS.length());
-          reject(cmd, "branch " + n + " not found");
+          reject(
+              cmd,
+              RejectionReason.create(MetricBucket.BRANCH_NOT_FOUND, "branch " + n + " not found"));
         } else {
-          reject(cmd, ref + " not found");
+          reject(cmd, RejectionReason.create(MetricBucket.REF_NOT_FOUND, ref + " not found"));
         }
         return;
       }
@@ -2067,17 +2249,21 @@
       magicBranch.dest = BranchNameKey.create(project.getNameKey(), ref);
       magicBranch.perm = permissions.ref(ref);
 
-      Optional<AuthException> err =
-          checkRefPermission(magicBranch.perm, RefPermission.READ)
-              .map(Optional::of)
-              .orElse(checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE));
+      Optional<AuthException> err = checkRefPermission(magicBranch.perm, RefPermission.READ);
+      if (err.isEmpty()) {
+        err = checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
+      }
       if (err.isPresent()) {
         rejectProhibited(cmd, err.get());
         return;
       }
 
       if (magicBranch.isPrivate && magicBranch.removePrivate) {
-        reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+        reject(
+            cmd,
+            RejectionReason.create(
+                MetricBucket.INVALID_OPTION,
+                "the options 'private' and 'remove-private' are mutually exclusive"));
         return;
       }
 
@@ -2090,17 +2276,26 @@
           magicBranch.isPrivate || (privateByDefault && !magicBranch.removePrivate);
 
       if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
-        reject(cmd, "private changes are disabled");
+        reject(
+            cmd,
+            RejectionReason.create(MetricBucket.INVALID_OPTION, "private changes are disabled"));
         return;
       }
 
       if (magicBranch.workInProgress && magicBranch.ready) {
-        reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+        reject(
+            cmd,
+            RejectionReason.create(
+                MetricBucket.INVALID_OPTION,
+                "the options 'wip' and 'ready' are mutually exclusive"));
         return;
       }
       if (magicBranch.publishComments && magicBranch.noPublishComments) {
         reject(
-            cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
+            cmd,
+            RejectionReason.create(
+                MetricBucket.INVALID_OPTION,
+                "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive"));
         return;
       }
 
@@ -2127,17 +2322,25 @@
       try {
         if (magicBranch.merged) {
           if (magicBranch.base != null) {
-            reject(cmd, "cannot use merged with base");
+            reject(
+                cmd,
+                RejectionReason.create(MetricBucket.INVALID_OPTION, "cannot use merged with base"));
             return;
           }
           Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch());
           if (refTip == null) {
-            reject(cmd, magicBranch.dest.branch() + " not found");
+            reject(
+                cmd,
+                RejectionReason.create(
+                    MetricBucket.BRANCH_NOT_FOUND, magicBranch.dest.branch() + " not found"));
             return;
           }
           RevCommit branchTip = globalRevWalk.parseCommit(refTip.getObjectId());
           if (!globalRevWalk.isMergedInto(tip, branchTip)) {
-            reject(cmd, "not merged into branch");
+            reject(
+                cmd,
+                RejectionReason.create(
+                    MetricBucket.NOT_MERGED_INTO_BRANCH, "not merged into branch"));
             return;
           }
         }
@@ -2159,10 +2362,11 @@
             try {
               magicBranch.baseCommit.add(globalRevWalk.parseCommit(id));
             } catch (IncorrectObjectTypeException notCommit) {
-              reject(cmd, "base must be a commit");
+              reject(
+                  cmd, RejectionReason.create(MetricBucket.INVALID_BASE, "base must be a commit"));
               return;
             } catch (MissingObjectException e) {
-              reject(cmd, "base not found");
+              reject(cmd, RejectionReason.create(MetricBucket.INVALID_BASE, "base not found"));
               return;
             } catch (IOException e) {
               throw new StorageException(
@@ -2184,7 +2388,10 @@
             // branch does not exist yet. This allows to push initial code for review to an empty
             // repository and to review an initial project configuration.
             if (!ref.equals(readHEAD(repo)) && !ref.equals(RefNames.REFS_CONFIG)) {
-              reject(cmd, magicBranch.dest.branch() + " not found");
+              reject(
+                  cmd,
+                  RejectionReason.create(
+                      MetricBucket.BRANCH_NOT_FOUND, magicBranch.dest.branch() + " not found"));
               return;
             }
           }
@@ -2238,7 +2445,8 @@
           globalRevWalk.markStart(tip);
           globalRevWalk.markStart(h);
           if (globalRevWalk.next() == null) {
-            reject(cmd, "no common ancestry");
+            reject(
+                cmd, RejectionReason.create(MetricBucket.NO_COMMON_ANCESTRY, "no common ancestry"));
             return false;
           }
         } finally {
@@ -2281,15 +2489,17 @@
       if (change.isClosed()) {
         reject(
             cmd,
-            changeFormatter.changeClosed(
-                ChangeReportFormatter.Input.builder().setChange(change).build()));
+            RejectionReason.create(
+                MetricBucket.CHANGE_IS_CLOSED,
+                changeFormatter.changeClosed(
+                    ChangeReportFormatter.Input.builder().setChange(change).build())));
         return false;
       }
 
       ReplaceRequest req =
           new ReplaceRequest(globalRevWalk, change.getId(), newCommit, cmd, checkMergedInto);
       if (replaceByChange.containsKey(req.ontoChange)) {
-        reject(cmd, "duplicate request");
+        reject(cmd, RejectionReason.create(MetricBucket.DUPLICATE_REQUEST, "duplicate request"));
         return false;
       }
 
@@ -2422,7 +2632,10 @@
             logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges);
             reject(
                 magicBranch.cmd,
-                "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
+                RejectionReason.create(
+                    MetricBucket.TOO_MANY_CHANGES,
+                    "the number of pushed changes in a batch exceeds the max limit "
+                        + maxBatchChanges));
             return ImmutableList.of();
           }
 
@@ -2465,8 +2678,10 @@
           if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
             reject(
                 magicBranch.cmd,
-                "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
-                    + "to override please set the base manually");
+                RejectionReason.create(
+                    MetricBucket.CANNOT_PUSH_MERGE_WITH_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+                    "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
+                        + "to override please set the base manually"));
             logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
             // TODO(dborowitz): Should we early return here?
           }
@@ -2494,7 +2709,10 @@
 
           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);
+            reject(
+                magicBranch.cmd,
+                RejectionReason.create(
+                    MetricBucket.DUPLICATE_CHANGE_ID, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES));
             return ImmutableList.of();
           }
 
@@ -2510,7 +2728,10 @@
             // a different Change-Id. In practice, we should never see
             // this error message as Change-Id should be unique per branch.
             //
-            reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
+            reject(
+                magicBranch.cmd,
+                RejectionReason.create(
+                    MetricBucket.DUPLICATE_CHANGE, p.changeKey.get() + " has duplicates"));
             return ImmutableList.of();
           }
 
@@ -2524,7 +2745,11 @@
               if (pending.size() == 1) {
                 // There are no commits left to check, all commits in pending were already
                 // current PatchSet of the corresponding target changes.
-                reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+                reject(
+                    magicBranch.cmd,
+                    RejectionReason.create(
+                        MetricBucket.COMMIT_ALREADY_EXISTS_IN_CHANGE,
+                        "commit(s) already exists (as current patchset)"));
               } else {
                 // Commit is already current PatchSet.
                 // Remove from pending and try next commit.
@@ -2541,7 +2766,9 @@
 
           if (changes.isEmpty()) {
             if (!isValidChangeId(p.changeKey.get())) {
-              reject(magicBranch.cmd, "invalid Change-Id");
+              reject(
+                  magicBranch.cmd,
+                  RejectionReason.create(MetricBucket.INVALID_CHANGE_ID, "invalid Change-Id"));
               return ImmutableList.of();
             }
 
@@ -2549,7 +2776,11 @@
             // double check against the existing refs
             if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
-                reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+                reject(
+                    magicBranch.cmd,
+                    RejectionReason.create(
+                        MetricBucket.COMMIT_ALREADY_EXISTS_IN_CHANGE,
+                        "commit(s) already exists (as current patchset)"));
                 return ImmutableList.of();
               }
               itr.remove();
@@ -2569,11 +2800,15 @@
       }
 
       if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
-        reject(magicBranch.cmd, "no new changes");
+        reject(
+            magicBranch.cmd, RejectionReason.create(MetricBucket.NO_NEW_CHANGES, "no new changes"));
         return ImmutableList.of();
       }
       if (!newChanges.isEmpty() && magicBranch.edit) {
-        reject(magicBranch.cmd, "edit is not supported for new changes");
+        reject(
+            magicBranch.cmd,
+            RejectionReason.create(
+                MetricBucket.CANNOT_EDIT_NEW_CHANGE, "edit is not supported for new changes"));
         return ImmutableList.copyOf(newChanges);
       }
 
@@ -2679,7 +2914,9 @@
                           + c.getShortMessage(),
                       ValidationMessage.Type.ERROR));
             }
-            reject(magicBranch.cmd, "implicit merges detected");
+            reject(
+                magicBranch.cmd,
+                RejectionReason.create(MetricBucket.IMPLICIT_MERGE, "implicit merges detected"));
           }
         }
       }
@@ -2730,18 +2967,27 @@
 
   private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
     try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) {
-      List<ChangeData> byBranchKeyExactMatch =
-          queryProvider.get().byBranchKey(magicBranch.dest, key).stream()
-              .filter(cd -> cd.change().getKey().equals(key))
-              .collect(toList());
-      return new ChangeLookup(c, key, byBranchKeyExactMatch);
+      List<ChangeData> byBranchKey =
+          retryHelper
+              .changeIndexQuery(
+                  "lookupByChangeKey",
+                  q ->
+                      q.byBranchKey(magicBranch.dest, key).stream()
+                          .filter(cd -> cd.change().getKey().equals(key))
+                          .collect(toList()))
+              .call();
+      return new ChangeLookup(c, key, byBranchKey);
     }
   }
 
   private ChangeLookup lookupByCommit(RevCommit c) {
     try (TraceTimer traceTimer = newTimer("lookupByCommit")) {
-      return new ChangeLookup(
-          c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+      List<ChangeData> byBranchCommit =
+          retryHelper
+              .changeIndexQuery(
+                  "lookupByCommit", q -> q.byBranchCommit(magicBranch.dest, c.getName()))
+              .call();
+      return new ChangeLookup(c, null, byBranchCommit);
     }
   }
 
@@ -3038,7 +3284,10 @@
         throws IOException, PermissionBackendException {
       try (TraceTimer traceTimer = newTimer("validateNewPatchSetNoteDb")) {
         if (notes == null) {
-          reject(inputCommand, "change " + ontoChange + " not found");
+          reject(
+              inputCommand,
+              RejectionReason.create(
+                  MetricBucket.CHANGE_NOT_FOUND, "change " + ontoChange + " not found"));
           return false;
         }
 
@@ -3056,7 +3305,10 @@
                       .limit(100) // Enough for "normal" changes.
                       .map(PatchSet.Id::getId)
                       .collect(Collectors.toList())));
-          reject(inputCommand, "change " + ontoChange + " missing revisions");
+          reject(
+              inputCommand,
+              RejectionReason.create(
+                  MetricBucket.MISSING_REVISION, "change " + ontoChange + " missing revisions"));
           return false;
         }
 
@@ -3064,24 +3316,36 @@
 
         // Not allowed to create a new patch set if the current patch set is locked.
         if (psUtil.isPatchSetLocked(notes)) {
-          reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+          reject(
+              inputCommand,
+              RejectionReason.create(
+                  MetricBucket.PATCH_SET_LOCKED, "cannot add patch set to " + ontoChange + "."));
           return false;
         }
 
         if (!permissions.change(notes).test(ChangePermission.ADD_PATCH_SET)) {
-          reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+          reject(
+              inputCommand,
+              RejectionReason.create(
+                  MetricBucket.CANNOT_ADD_PATCH_SET,
+                  "cannot add patch set to " + ontoChange + "."));
           return false;
         }
 
         if (change.isClosed()) {
-          reject(inputCommand, "change " + ontoChange + " closed");
+          reject(
+              inputCommand,
+              RejectionReason.create(
+                  MetricBucket.CHANGE_IS_CLOSED, "change " + ontoChange + " closed"));
           return false;
         } else if (revisions.containsKey(newCommit)) {
           reject(
               inputCommand,
-              String.format(
-                  "commit %s already exists in change %s",
-                  newCommit.name().substring(0, 10), change.getId()));
+              RejectionReason.create(
+                  MetricBucket.COMMIT_ALREADY_EXISTS_IN_CHANGE,
+                  String.format(
+                      "commit %s already exists in change %s",
+                      newCommit.name().substring(0, 10), change.getId())));
           return false;
         }
 
@@ -3092,8 +3356,10 @@
           //  without the option to turn that off.
           reject(
               inputCommand,
-              "commit already exists (in the project): "
-                  + existingPatchSetsWithSameCommit.get(0).toRefName());
+              RejectionReason.create(
+                  MetricBucket.COMMIT_ALREADY_EXISTS_IN_PROJECT,
+                  "commit already exists (in the project): "
+                      + existingPatchSetsWithSameCommit.get(0).toRefName()));
           return false;
         }
 
@@ -3103,7 +3369,10 @@
             // very common error due to users making a new commit rather than
             // amending when trying to address review comments.
             if (globalRevWalk.isMergedInto(prior, newCommit)) {
-              reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+              reject(
+                  inputCommand,
+                  RejectionReason.create(
+                      MetricBucket.DUPLICATE_CHANGE_ID, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES));
               return false;
             }
           }
@@ -3121,7 +3390,11 @@
           && !user.getAccountId().equals(change.getOwner())) {
         if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
           if (!permissions.change(notes).test(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)) {
-            reject(inputCommand, ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP);
+            reject(
+                inputCommand,
+                RejectionReason.create(
+                    MetricBucket.CANNOT_TOGGLE_WIP,
+                    ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP));
           }
         }
       }
@@ -3188,7 +3461,10 @@
             // replace edit
             cmd =
                 new ReceiveCommand(
-                    edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
+                    edit.get().getEditCommit(),
+                    newCommitId,
+                    edit.get().getRefName(),
+                    ReceiveCommand.Type.UPDATE_NONFASTFORWARD);
           } else {
             // delete old edit ref on rebase
             prev =
@@ -3286,9 +3562,8 @@
       }
     }
 
-    @Nullable
-    String getRejectMessage() {
-      return replaceOp != null ? replaceOp.getRejectMessage() : null;
+    Optional<RejectionReason> getRejectionReason() {
+      return replaceOp != null ? replaceOp.getRejectionReason() : Optional.empty();
     }
 
     Optional<String> getOutdatedApprovalsMessage() {
@@ -3383,7 +3658,7 @@
         messages.addAll(refValidators.validateForRefOperation());
       } catch (RefOperationValidationException e) {
         messages.addAll(e.getMessages());
-        reject(cmd, e.getMessage());
+        reject(cmd, RejectionReason.create(MetricBucket.REJECTED_BY_VALIDATOR, e.getMessage()));
         return false;
       }
 
@@ -3408,7 +3683,11 @@
               && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION);
       if (skipValidation) {
         if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
-          reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
+          reject(
+              cmd,
+              RejectionReason.create(
+                  MetricBucket.SIGNED_OFF_BY_REQUIRED,
+                  "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION));
           return;
         }
 
@@ -3419,7 +3698,11 @@
           return;
         }
         if (!Iterables.isEmpty(rejectCommits)) {
-          reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+          reject(
+              cmd,
+              RejectionReason.create(
+                  MetricBucket.BANNED_COMMIT,
+                  "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION));
         }
       }
 
@@ -3443,8 +3726,11 @@
             logger.atFine().log("Number of new commits exceeds limit of %d", limit);
             reject(
                 cmd,
-                String.format(
-                    "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
+                RejectionReason.create(
+                    MetricBucket.TOO_MANY_COMMITS,
+                    String.format(
+                        "more than %d commits, and %s not set",
+                        limit, PUSH_OPTION_SKIP_VALIDATION)));
             return;
           }
           if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
@@ -3685,17 +3971,26 @@
     return TraceContext.newTimer(clazz.getSimpleName() + "#" + name, metadataBuilder.build());
   }
 
-  private static void reject(ReceiveCommand cmd, String why) {
-    logger.atFine().log("Rejecting command '%s': %s", cmd, why);
-    cmd.setResult(REJECTED_OTHER_REASON, why);
+  private void reject(ReceiveCommand cmd, RejectionReason reason) {
+    logger.atFine().log("Rejecting command '%s': %s", cmd, reason.why());
+
+    String pushKind = (MagicBranch.isMagicBranch(cmd.getRefName()) ? "magic push" : "direct push");
+    if (serviceUserClassifier.isServiceUser(user.getAccountId())) {
+      pushKind += " by service user";
+    }
+    metrics.rejectCount.increment(pushKind, reason.metricBucket(), reason.statusCode());
+
+    cmd.setResult(REJECTED_OTHER_REASON, reason.why());
+
+    rejectionReasons.put(cmd, reason);
   }
 
-  private static void rejectRemaining(Collection<ReceiveCommand> commands, String why) {
-    rejectRemaining(commands.stream(), why);
+  private void rejectRemaining(Collection<ReceiveCommand> commands, RejectionReason reason) {
+    rejectRemaining(commands.stream(), reason);
   }
 
-  private static void rejectRemaining(Stream<ReceiveCommand> commands, String why) {
-    commands.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED).forEach(cmd -> reject(cmd, why));
+  private void rejectRemaining(Stream<ReceiveCommand> commands, RejectionReason reason) {
+    commands.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED).forEach(cmd -> reject(cmd, reason));
   }
 
   private static boolean isHead(ReceiveCommand cmd) {
diff --git a/java/com/google/gerrit/server/git/receive/RejectionReason.java b/java/com/google/gerrit/server/git/receive/RejectionReason.java
new file mode 100644
index 0000000..ef36538
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/RejectionReason.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.permissions.PermissionDeniedException;
+import java.util.Locale;
+
+@AutoValue
+public abstract class RejectionReason {
+  private static final int SC_CLIENT_CLOSED_REQUEST = 499;
+
+  public enum MetricBucket {
+    ACCOUNT_NOT_FOUND(SC_NOT_FOUND),
+    CANNOT_ADD_PATCH_SET(SC_FORBIDDEN),
+    CANNOT_COMBINE_NORMAL_AND_MAGIC_PUSHES(SC_BAD_REQUEST),
+    CANNOT_CREATE_REF_BECAUSE_IT_ALREADY_EXISTS(SC_CONFLICT),
+    CANNOT_DELETE_CHANGES(SC_METHOD_NOT_ALLOWED),
+    CANNOT_DELETE_PROJECT_CONFIGURATION(SC_METHOD_NOT_ALLOWED),
+    CANNOT_EDIT_NEW_CHANGE(SC_CONFLICT),
+    CANNOT_PUSH_MERGE_WITH_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET(SC_BAD_REQUEST),
+    CANNOT_SKIP_VALIDATION_FOR_MAGIC_PUSH(SC_BAD_REQUEST),
+    CANNOT_TOGGLE_WIP(SC_FORBIDDEN),
+    CHANGE_IS_CLOSED(SC_CONFLICT),
+    CHANGE_NOT_FOUND(SC_NOT_FOUND),
+    CLIENT_CLOSED_REQUEST(SC_CLIENT_CLOSED_REQUEST),
+    CLIENT_ERROR(SC_BAD_REQUEST),
+    CLIENT_PROVIDED_DEADLINE_EXCEEDED(SC_REQUEST_TIMEOUT),
+    COMMIT_ALREADY_EXISTS_IN_CHANGE(SC_CONFLICT),
+    COMMIT_ALREADY_EXISTS_IN_PROJECT(SC_CONFLICT),
+    CONFLICT(SC_CONFLICT),
+    BANNED_COMMIT(SC_CONFLICT),
+    BRANCH_NOT_FOUND(SC_NOT_FOUND),
+    DUPLICATE_CHANGE(SC_BAD_REQUEST),
+    DUPLICATE_CHANGE_ID(SC_BAD_REQUEST),
+    DUPLICATE_REQUEST(SC_BAD_REQUEST),
+    HELP_REQUESTED(SC_OK),
+    IMPLICIT_MERGE(SC_BAD_REQUEST),
+    INTERNAL_SERVER_ERROR(SC_INTERNAL_SERVER_ERROR),
+    INVALID_BASE(SC_BAD_REQUEST),
+    INVALID_BRANCH_SYNTAX(SC_BAD_REQUEST),
+    INVALID_CHANGE_ID(SC_BAD_REQUEST),
+    INVALID_DEADLINE(SC_BAD_REQUEST),
+    INVALID_HEAD(SC_BAD_REQUEST),
+    INVALID_OPTION(SC_BAD_REQUEST),
+    INVALID_PROJECT_CONFIGURATION_UPDATE(SC_BAD_REQUEST),
+    INVALID_REF(SC_BAD_REQUEST),
+    MISSING_REVISION(SC_INTERNAL_SERVER_ERROR),
+    NO_COMMON_ANCESTRY(SC_BAD_REQUEST),
+    NO_NEW_CHANGES(SC_BAD_REQUEST),
+    NOT_A_COMMIT(SC_BAD_REQUEST),
+    NOT_MERGED_INTO_BRANCH(SC_BAD_REQUEST),
+    NOTEDB_UPDATE_WITHOUT_ACCESS_DATABASE_PERMISSION(SC_FORBIDDEN),
+    NOTEDB_UPDATE_WITHOUT_ALLOW_OPTION(SC_BAD_REQUEST),
+    PATCH_SET_LOCKED(SC_CONFLICT),
+    PROHIBITED(SC_FORBIDDEN),
+    PROJECT_CONFIG_UPDATE_NOT_ALLOWED(SC_FORBIDDEN),
+    PROJECT_NOT_WRITABLE(SC_CONFLICT),
+    REF_NOT_FOUND(SC_NOT_FOUND),
+    REJECTED_BY_VALIDATOR(SC_BAD_REQUEST),
+    SERVER_DEADLINE_EXCEEDED(SC_INTERNAL_SERVER_ERROR),
+    SIGNED_OFF_BY_REQUIRED(SC_BAD_REQUEST),
+    SUBMIT_ERROR(SC_INTERNAL_SERVER_ERROR),
+    TOPIC_TOO_LARGE(SC_BAD_REQUEST),
+    TOO_MANY_CHANGES(SC_BAD_REQUEST),
+    TOO_MANY_COMMITS(SC_BAD_REQUEST),
+    UNKNOWN_COMMAND_TYPE(SC_BAD_REQUEST);
+
+    private final int statusCode;
+
+    private MetricBucket(int statusCode) {
+      this.statusCode = statusCode;
+    }
+  }
+
+  public static RejectionReason create(MetricBucket metricBucket, String why) {
+    return new AutoValue_RejectionReason(metricBucket.statusCode, metricBucket.name(), why);
+  }
+
+  public static RejectionReason create(PermissionDeniedException permissionDenied) {
+    return new AutoValue_RejectionReason(
+        SC_FORBIDDEN,
+        "CANNOT_"
+            + permissionDenied
+                .getPermission()
+                .permissionName()
+                .toUpperCase(Locale.US)
+                .replaceAll(" ", "_"),
+        "prohibited by Gerrit: "
+            + PermissionDeniedException.MESSAGE_PREFIX
+            + permissionDenied.describePermission());
+  }
+
+  public abstract int statusCode();
+
+  public abstract String metricBucket();
+
+  public abstract String why();
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index c0ffde3..e31f3ac 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
+import com.google.gerrit.server.git.receive.RejectionReason.MetricBucket;
 import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -158,7 +159,7 @@
   private ChangeKind changeKind;
   private String mailMessage;
   private ApprovalCopier.Result approvalCopierResult;
-  private String rejectMessage;
+  private RejectionReason rejectionReason;
   private MergedByPushOp mergedByPushOp;
   private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
@@ -262,7 +263,7 @@
     notes = ctx.getNotes();
     Change change = notes.getChange();
     if (change == null || change.isClosed()) {
-      rejectMessage = CHANGE_IS_CLOSED;
+      rejectionReason = RejectionReason.create(MetricBucket.CHANGE_IS_CLOSED, CHANGE_IS_CLOSED);
       return false;
     }
     if (groups.isEmpty()) {
@@ -453,6 +454,8 @@
             + ".";
       case TRIVIAL_REBASE:
         return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
+      case TRIVIAL_REBASE_WITH_MESSAGE_UPDATE:
+        return ": Patch Set " + priorPatchSetId.get() + " was rebased. Commit message was updated.";
       case NO_CHANGE:
         return ": New patch set was added with same tree, parent "
             + (commit.getParentCount() != 1 ? "trees" : "tree")
@@ -588,8 +591,8 @@
     return notes.getChange();
   }
 
-  public String getRejectMessage() {
-    return rejectMessage;
+  public Optional<RejectionReason> getRejectionReason() {
+    return Optional.ofNullable(rejectionReason);
   }
 
   public Optional<String> getOutdatedApprovalsMessage() {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 5c7d524..2311240 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -356,6 +356,7 @@
           throw new CommitValidationException(MISSING_CHANGE_ID_MSG, messages);
         }
       } else if (idList.size() > 1) {
+        messages.add(getMultipleChangeIdsErrorMsg(idList));
         throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
       } else {
         String v = idList.get(0).trim();
@@ -391,6 +392,24 @@
           ValidationMessage.Type.ERROR);
     }
 
+    private CommitValidationMessage getMultipleChangeIdsErrorMsg(List<String> idList) {
+      return new CommitValidationMessage(
+          MULTIPLE_CHANGE_ID_MSG
+              + "\n"
+              + "\nHint: the following Change-Ids were found:\n"
+              + idList.stream()
+                  .map(
+                      id ->
+                          "* "
+                              + id
+                              + " ["
+                              + (CHANGE_ID.matcher(id.trim()).matches() ? "VALID" : "INVALID")
+                              + "]")
+                  .collect(Collectors.joining("\n"))
+              + "\n",
+          ValidationMessage.Type.ERROR);
+    }
+
     private String getCommitMessageHookInstallationHint() {
       if (installCommitMsgHookCommand != null) {
         return installCommitMsgHookCommand;
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index 79d53ac..a51a425 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -39,7 +39,8 @@
    * @param destProject the destination project
    * @param destBranch the destination branch
    * @param patchSetId the patch set ID
-   * @param caller the user who initiated the merge request
+   * @param caller the identity of the user that is recorded as the one performing the merge. In
+   *     case of impersonation {@code caller.getRealUser()} contains the user triggering the merge.
    * @throws MergeValidationException if the commit fails to validate
    */
   void onPreMerge(
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 710e688..c8a3d1e 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -196,7 +196,9 @@
             if (!oldParent.equals(newParent)) {
               if (!allowProjectOwnersToChangeParent) {
                 try {
-                  if (!permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+                  if (!permissionBackend
+                      .user(caller.getRealUser())
+                      .test(GlobalPermission.ADMINISTRATE_SERVER)) {
                     throw new MergeValidationException(SET_BY_ADMIN);
                   }
                 } catch (PermissionBackendException e) {
@@ -206,7 +208,7 @@
               } else {
                 try {
                   permissionBackend
-                      .user(caller)
+                      .user(caller.getRealUser())
                       .project(destProject.getNameKey())
                       .check(ProjectPermission.WRITE_CONFIG);
                 } catch (AuthException e) {
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
index 72e15ee..d466041 100644
--- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -21,20 +21,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.ScheduleConfig;
-import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -59,52 +51,6 @@
 public class PeriodicGroupIndexer implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class PeriodicGroupIndexerModule extends LifecycleModule {
-    @Override
-    protected void configure() {
-      listener().to(Lifecycle.class);
-    }
-  }
-
-  private static class Lifecycle implements LifecycleListener {
-    private final Config cfg;
-    private final WorkQueue queue;
-    private final PeriodicGroupIndexer runner;
-
-    @Inject
-    Lifecycle(@GerritServerConfig Config cfg, WorkQueue queue, PeriodicGroupIndexer runner) {
-      this.cfg = cfg;
-      this.queue = queue;
-      this.runner = runner;
-    }
-
-    @Override
-    public void start() {
-      boolean runOnStartup = cfg.getBoolean("index", "scheduledIndexer", "runOnStartup", true);
-      if (runOnStartup) {
-        runner.run();
-      }
-
-      boolean isEnabled = cfg.getBoolean("index", "scheduledIndexer", "enabled", true);
-      if (!isEnabled) {
-        logger.atWarning().log("index.scheduledIndexer is disabled");
-        return;
-      }
-
-      Schedule schedule =
-          ScheduleConfig.builder(cfg, "index")
-              .setSubsection("scheduledIndexer")
-              .buildSchedule()
-              .orElseGet(() -> Schedule.createOrFail(TimeUnit.MINUTES.toMillis(5), "00:00"));
-      queue.scheduleAtFixedRate(runner, schedule);
-    }
-
-    @Override
-    public void stop() {
-      // handled by WorkQueue.stop() already
-    }
-  }
-
   private final AllUsersName allUsersName;
   private final GitRepositoryManager repoManager;
   private final Provider<GroupIndexer> groupIndexerProvider;
diff --git a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
index 235ca4f..3ba087e 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
@@ -167,7 +167,7 @@
         .map(Account::getName)
         // Historically, the database did not enforce relational integrity, so it is
         // possible for groups to have non-existing members.
-        .orElse("No Account for Id #" + accountId);
+        .orElseGet(() -> "No Account for Id #" + accountId);
   }
 
   private PersonIdent getParsableAuthorIdent(
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index 96870ea..c048e3c 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -25,6 +25,7 @@
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
@@ -34,6 +35,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
@@ -42,11 +44,13 @@
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.AccountIndexerImpl;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexDefinition;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.change.StalenessChecker;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexDefinition;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
@@ -129,6 +133,8 @@
     bind(ChangeIndexCollection.class);
     listener().to(ChangeIndexCollection.class);
     factory(ChangeIndexer.Factory.class);
+    factory(StalenessChecker.Factory.class);
+    factory(AllChangesIndexer.Factory.class);
 
     bind(GroupIndexRewriter.class);
     // GroupIndexCollection is already bound very high up in SchemaModule.
@@ -255,6 +261,13 @@
     return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch", true));
   }
 
+  @Provides
+  @Singleton
+  StalenessChecker getChangeStalenessChecker(
+      ChangeIndexCollection indexes, GitRepositoryManager repoManager, IndexConfig indexConfig) {
+    return new StalenessChecker(indexes, repoManager, indexConfig);
+  }
+
   @Singleton
   private static class ShutdownIndexExecutors implements LifecycleListener {
     private final ListeningExecutorService interactiveExecutor;
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index f2a2904..f81a9ce 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index;
 
+import static com.google.gerrit.server.index.change.ChangeField.CHANGENUM_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
@@ -81,10 +82,18 @@
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT_SPEC.getName()) && fs.contains(NUMERIC_ID_STR_SPEC.getName())) {
+
+    Set<String> requiredFields =
+        CHANGENUM_SPEC.getName() != null
+            ? ImmutableSet.of(
+                NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName(), CHANGENUM_SPEC.getName())
+            : ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName());
+
+    if (fs.containsAll(requiredFields)) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName()));
+
+    return Sets.union(fs, ImmutableSet.copyOf(requiredFields));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/index/IndexVersionReindexer.java b/java/com/google/gerrit/server/index/IndexVersionReindexer.java
new file mode 100644
index 0000000..84be97e
--- /dev/null
+++ b/java/com/google/gerrit/server/index/IndexVersionReindexer.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SiteIndexer;
+import com.google.inject.Inject;
+import java.util.concurrent.Future;
+
+public class IndexVersionReindexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private ListeningExecutorService executor;
+
+  @Inject
+  IndexVersionReindexer(@IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.executor = executor;
+  }
+
+  public <K, V, I extends Index<K, V>> Future<SiteIndexer.Result> reindex(
+      IndexDefinition<K, V, I> def, int version, boolean reuse, boolean notifyListeners) {
+    I index = def.getIndexCollection().getWriteIndex(version);
+    SiteIndexer<K, V, I> siteIndexer = def.getSiteIndexer(reuse);
+    return executor.submit(
+        () -> {
+          String name = def.getName();
+          logger.atInfo().log("Starting reindex of %s version %d", name, version);
+          SiteIndexer.Result result = siteIndexer.indexAll(index, notifyListeners);
+          if (result.success()) {
+            logger.atInfo().log("Reindex %s version %s complete", name, version);
+          } else {
+            logger.atInfo().log(
+                "Reindex %s version %s failed. Successfully indexed %s, failed to index %s",
+                name, version, result.doneCount(), result.failedCount());
+          }
+          return result;
+        });
+  }
+}
diff --git a/java/com/google/gerrit/server/index/OnlineReindexer.java b/java/com/google/gerrit/server/index/OnlineReindexer.java
index eef394d..3701ab8 100644
--- a/java/com/google/gerrit/server/index/OnlineReindexer.java
+++ b/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -42,18 +42,21 @@
   private final PluginSetContext<OnlineUpgradeListener> listeners;
   private I index;
   private final AtomicBoolean running = new AtomicBoolean();
+  private final boolean reuseExistingDocuments;
 
   public OnlineReindexer(
       IndexDefinition<K, V, I> def,
       int oldVersion,
       int newVersion,
-      PluginSetContext<OnlineUpgradeListener> listeners) {
+      PluginSetContext<OnlineUpgradeListener> listeners,
+      boolean reuseExistingDocuments) {
     this.name = def.getName();
     this.indexes = def.getIndexCollection();
     this.batchIndexer = def.getSiteIndexer();
     this.oldVersion = oldVersion;
     this.newVersion = newVersion;
     this.listeners = listeners;
+    this.reuseExistingDocuments = reuseExistingDocuments;
   }
 
   /** Starts the background process. */
@@ -106,10 +109,10 @@
         "Starting online reindex of %s from schema version %s to %s",
         name, version(indexes.getSearchIndex()), version(index));
 
-    if (oldVersion != newVersion) {
+    if (!reuseExistingDocuments && oldVersion != newVersion) {
       index.deleteAll();
     }
-    SiteIndexer.Result result = batchIndexer.indexAll(index);
+    SiteIndexer.Result result = batchIndexer.indexAll(index, false);
     if (!result.success()) {
       logger.atSevere().log(
           "Online reindex of %s schema version %s failed. Successfully"
diff --git a/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
index 39930a6..2c38caf 100644
--- a/java/com/google/gerrit/server/index/VersionManager.java
+++ b/java/com/google/gerrit/server/index/VersionManager.java
@@ -59,6 +59,7 @@
   }
 
   protected final boolean onlineUpgrade;
+  protected final boolean reuseExistingDocuments;
   protected final String runReindexMsg;
   protected final SitePaths sitePaths;
 
@@ -72,7 +73,8 @@
       SitePaths sitePaths,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs,
-      boolean onlineUpgrade) {
+      boolean onlineUpgrade,
+      boolean reuseExistingDocuments) {
     this.sitePaths = sitePaths;
     this.listeners = listeners;
     this.defs = Maps.newHashMapWithExpectedSize(defs.size());
@@ -82,6 +84,7 @@
 
     this.reindexers = Maps.newHashMapWithExpectedSize(defs.size());
     this.onlineUpgrade = onlineUpgrade;
+    this.reuseExistingDocuments = reuseExistingDocuments;
     this.runReindexMsg =
         "No index versions for index '%s' ready; run java -jar "
             + sitePaths.gerrit_war.toAbsolutePath()
@@ -190,7 +193,7 @@
       if (!reindexers.containsKey(def.getName())) {
         int latest = write.get(0).version;
         OnlineReindexer<K, V, I> reindexer =
-            new OnlineReindexer<>(def, search.version, latest, listeners);
+            new OnlineReindexer<>(def, search.version, latest, listeners, reuseExistingDocuments);
         reindexers.put(def.getName(), reindexer);
       }
     }
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 3935108..6fd62a0 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -43,7 +43,8 @@
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -52,6 +53,7 @@
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -65,6 +67,13 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    AllChangesIndexer create();
+
+    AllChangesIndexer create(boolean reuseExistingDocuments);
+  }
+
   private MultiProgressMonitor mpm;
   private VolatileTask doneTask;
   private Task failedTask;
@@ -84,25 +93,54 @@
   private final GitRepositoryManager repoManager;
   private final ListeningExecutorService executor;
   private final ChangeIndexer.Factory indexerFactory;
+  private final StalenessChecker.Factory stalenessCheckerFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ProjectCache projectCache;
   private final Set<Project.NameKey> projectsToSkip;
+  private final boolean reuseExistingDocuments;
 
-  @Inject
+  @AssistedInject
   AllChangesIndexer(
       MultiProgressMonitor.Factory multiProgressMonitorFactory,
       ChangeData.Factory changeDataFactory,
       GitRepositoryManager repoManager,
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       ChangeIndexer.Factory indexerFactory,
+      StalenessChecker.Factory stalenessCheckerFactory,
       ChangeNotes.Factory notesFactory,
       ProjectCache projectCache,
       @GerritServerConfig Config config) {
+    this(
+        multiProgressMonitorFactory,
+        changeDataFactory,
+        repoManager,
+        executor,
+        indexerFactory,
+        stalenessCheckerFactory,
+        notesFactory,
+        projectCache,
+        config,
+        config.getBoolean("index", null, "reuseExistingDocuments", false));
+  }
+
+  @AssistedInject
+  AllChangesIndexer(
+      MultiProgressMonitor.Factory multiProgressMonitorFactory,
+      ChangeData.Factory changeDataFactory,
+      GitRepositoryManager repoManager,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      ChangeIndexer.Factory indexerFactory,
+      StalenessChecker.Factory stalenessCheckerFactory,
+      ChangeNotes.Factory notesFactory,
+      ProjectCache projectCache,
+      @GerritServerConfig Config config,
+      @Assisted boolean reuseExistingDocuments) {
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.changeDataFactory = changeDataFactory;
     this.repoManager = repoManager;
     this.executor = executor;
     this.indexerFactory = indexerFactory;
+    this.stalenessCheckerFactory = stalenessCheckerFactory;
     this.notesFactory = notesFactory;
     this.projectCache = projectCache;
     this.projectsToSkip =
@@ -110,6 +148,7 @@
             .stream()
             .map(p -> Project.NameKey.parse(p))
             .collect(Collectors.toSet());
+    this.reuseExistingDocuments = reuseExistingDocuments;
   }
 
   @AutoValue
@@ -138,6 +177,11 @@
 
   @Override
   public Result indexAll(ChangeIndex index) {
+    return indexAll(index, true);
+  }
+
+  @Override
+  public Result indexAll(ChangeIndex index, boolean notifyListeners) {
     // The simplest approach to distribute indexing would be to let each thread grab a project
     // and index it fully. But if a site has one big project and 100s of small projects, then
     // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
@@ -160,7 +204,7 @@
     failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
     List<ListenableFuture<?>> futures;
     try {
-      futures = new SliceScheduler(index, ok).schedule();
+      futures = new SliceScheduler(index, ok, notifyListeners).schedule();
     } catch (ProjectsCollectionFailure e) {
       logger.atSevere().log("%s", e.getMessage());
       return Result.create(sw, false, 0, 0);
@@ -218,20 +262,27 @@
   }
 
   private class ProjectSliceIndexer implements Callable<Void> {
-    private final ChangeIndexer indexer;
     private final ProjectSlice projectSlice;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
+    private final Consumer<ChangeData> indexAction;
 
     private ProjectSliceIndexer(
         ChangeIndexer indexer,
         ProjectSlice projectSlice,
         ProgressMonitor done,
         ProgressMonitor failed) {
-      this.indexer = indexer;
       this.projectSlice = projectSlice;
       this.done = done;
       this.failed = failed;
+      if (reuseExistingDocuments) {
+        indexAction =
+            cd -> {
+              var unused = indexer.reindexIfStale(cd);
+            };
+      } else {
+        indexAction = cd -> indexer.index(cd);
+      }
     }
 
     @Override
@@ -271,7 +322,7 @@
         return;
       }
       try {
-        indexer.index(changeDataFactory.create(r.notes()));
+        indexAction.accept(changeDataFactory.create(r.notes()));
         done.update(1);
         verboseWriter.format(
             "Reindexed change %d (project: %s)\n", r.id().get(), r.notes().getProjectName().get());
@@ -313,6 +364,7 @@
   private class SliceScheduler {
     final ChangeIndex index;
     final AtomicBoolean ok;
+    final boolean notifyListeners;
     final AtomicInteger changeCount = new AtomicInteger(0);
     final AtomicInteger projectsFailed = new AtomicInteger(0);
     final List<ListenableFuture<?>> sliceIndexerFutures = new ArrayList<>();
@@ -320,9 +372,10 @@
     VolatileTask projTask = mpm.beginVolatileSubTask("project-slices");
     Task slicingProjects;
 
-    public SliceScheduler(ChangeIndex index, AtomicBoolean ok) {
+    public SliceScheduler(ChangeIndex index, AtomicBoolean ok, boolean notifyListeners) {
       this.index = index;
       this.ok = ok;
+      this.notifyListeners = notifyListeners;
     }
 
     private List<ListenableFuture<?>> schedule() throws ProjectsCollectionFailure {
@@ -330,7 +383,7 @@
       int projectCount = projects.size();
       slicingProjects = mpm.beginSubTask("Slicing projects", projectCount);
       for (Project.NameKey name : projects) {
-        sliceCreationFutures.add(executor.submit(new ProjectSliceCreator(name)));
+        sliceCreationFutures.add(executor.submit(new ProjectSliceCreator(name, notifyListeners)));
       }
 
       try {
@@ -360,10 +413,12 @@
     }
 
     private class ProjectSliceCreator implements Callable<Void> {
-      final Project.NameKey name;
+      private final Project.NameKey name;
+      private final boolean doNotifyListeners;
 
-      public ProjectSliceCreator(Project.NameKey name) {
+      public ProjectSliceCreator(Project.NameKey name, boolean notifyListeners) {
         this.name = name;
+        this.doNotifyListeners = notifyListeners;
       }
 
       @Override
@@ -385,13 +440,16 @@
 
             for (int slice = 0; slice < slices; slice++) {
               ProjectSlice projectSlice = ProjectSlice.create(name, slice, slices, metaIdByChange);
+              ChangeIndexer indexer;
+              if (reuseExistingDocuments) {
+                indexer =
+                    indexerFactory.create(
+                        executor, index, stalenessCheckerFactory.create(index), doNotifyListeners);
+              } else {
+                indexer = indexerFactory.create(executor, index, doNotifyListeners);
+              }
               ListenableFuture<?> future =
-                  executor.submit(
-                      reindexProjectSlice(
-                          indexerFactory.create(executor, index),
-                          projectSlice,
-                          doneTask,
-                          failedTask));
+                  executor.submit(reindexProjectSlice(indexer, projectSlice, doneTask, failedTask));
               String description = "project " + name + " (" + slice + "/" + slices + ")";
               addErrorListener(future, description, projTask, ok);
               sliceIndexerFutures.add(future);
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index c5afba4..045482a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_CHANGE_NUMBER;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
@@ -128,11 +129,20 @@
           .required()
           // The numeric change id is integer in string form
           .size(10)
-          .build(cd -> String.valueOf(cd.getVirtualId().get()));
+          .build(cd -> String.valueOf(cd.virtualId().get()));
 
   public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
       NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
 
+  public static final IndexedField<ChangeData, Integer> CHANGENUM_FIELD =
+      IndexedField.<ChangeData>integerBuilder("ChangeNumber")
+          .stored()
+          .required()
+          .build(cd -> cd.getId().get());
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHANGENUM_SPEC =
+      CHANGENUM_FIELD.integer(FIELD_CHANGE_NUMBER);
+
   /** Newer style Change-Id key. */
   public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
       IndexedField.<ChangeData>stringBuilder("ChangeId")
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
index dde9d86..342796b 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
@@ -14,20 +14,34 @@
 
 package com.google.gerrit.server.index.change;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 
 /** Bundle of service classes that make up the change index. */
 public class ChangeIndexDefinition extends IndexDefinition<Change.Id, ChangeData, ChangeIndex> {
 
+  private final AllChangesIndexer.Factory allChangesIndexerFactory;
+
   @Inject
   ChangeIndexDefinition(
       ChangeIndexCollection indexCollection,
       ChangeIndex.Factory indexFactory,
-      @Nullable AllChangesIndexer allChangesIndexer) {
-    super(ChangeSchemaDefinitions.INSTANCE, indexCollection, indexFactory, allChangesIndexer);
+      AllChangesIndexer.Factory allChangesIndexerFactory) {
+    super(ChangeSchemaDefinitions.INSTANCE, indexCollection, indexFactory, null);
+    this.allChangesIndexerFactory = allChangesIndexerFactory;
+  }
+
+  @Override
+  public SiteIndexer<Change.Id, ChangeData, ChangeIndex> getSiteIndexer() {
+    return allChangesIndexerFactory.create();
+  }
+
+  @Override
+  public SiteIndexer<Change.Id, ChangeData, ChangeIndex> getSiteIndexer(
+      boolean reuseExistingDocuments) {
+    return allChangesIndexerFactory.create(reuseExistingDocuments);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 2331255..fb02de6 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -222,6 +222,9 @@
         isIndexed.set(i);
         newChildren.add(c);
       } else if (nc == null /* cannot rewrite c */) {
+        if (c instanceof ChangeDataSource) {
+          changeSource.set(i);
+        }
         notIndexed.set(i);
         newChildren.add(c);
       } else {
@@ -236,6 +239,9 @@
     if (isIndexed.cardinality() == n) {
       return in; // All children are indexed, leave as-is for parent.
     } else if (notIndexed.cardinality() == n) {
+      if (changeSource.cardinality() == n) {
+        return copy(in, newChildren);
+      }
       return null; // Can't rewrite any children, so cannot rewrite in.
     } else if (rewritten.cardinality() == n) {
       // All children were rewritten.
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 562f9c4..fc666ad 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -70,7 +70,19 @@
   public interface Factory {
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
 
+    ChangeIndexer create(
+        ListeningExecutorService executor, ChangeIndex index, boolean notifyListeners);
+
+    ChangeIndexer create(
+        ListeningExecutorService executor,
+        ChangeIndex index,
+        StalenessChecker stalenessChecker,
+        boolean notifyListeners);
+
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndexCollection indexes);
+
+    ChangeIndexer create(
+        ListeningExecutorService executor, ChangeIndexCollection indexes, boolean notifyListeners);
   }
 
   @Nullable private final ChangeIndexCollection indexes;
@@ -84,6 +96,7 @@
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
   private final IsFirstInsertForEntry isFirstInsertForEntry;
+  private final boolean notifyListeners;
 
   private final Map<Change.Id, IndexTask> queuedIndexTasks = new ConcurrentHashMap<>();
   private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
@@ -101,6 +114,33 @@
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndex index,
       IsFirstInsertForEntry isFirstInsertForEntry) {
+    this(
+        cfg,
+        changeDataFactory,
+        notesFactory,
+        context,
+        indexedListeners,
+        stalenessChecker,
+        batchExecutor,
+        executor,
+        index,
+        true,
+        isFirstInsertForEntry);
+  }
+
+  @AssistedInject
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
+      ThreadLocalRequestContext context,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndex index,
+      @Assisted boolean notifyListeners,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -112,6 +152,34 @@
     this.index = index;
     this.indexes = null;
     this.isFirstInsertForEntry = isFirstInsertForEntry;
+    this.notifyListeners = notifyListeners;
+  }
+
+  @AssistedInject
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
+      ThreadLocalRequestContext context,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      IsFirstInsertForEntry isFirstInsertForEntry,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndex index,
+      @Assisted StalenessChecker stalenessChecker,
+      @Assisted boolean notifyListeners) {
+    this.executor = executor;
+    this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
+    this.context = context;
+    this.indexedListeners = indexedListeners;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
+    this.index = index;
+    this.indexes = null;
+    this.stalenessChecker = stalenessChecker;
+    this.notifyListeners = notifyListeners;
   }
 
   @AssistedInject
@@ -126,6 +194,33 @@
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndexCollection indexes,
       IsFirstInsertForEntry isFirstInsertForEntry) {
+    this(
+        cfg,
+        changeDataFactory,
+        notesFactory,
+        context,
+        indexedListeners,
+        stalenessChecker,
+        batchExecutor,
+        executor,
+        indexes,
+        true,
+        isFirstInsertForEntry);
+  }
+
+  @AssistedInject
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
+      ThreadLocalRequestContext context,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndexCollection indexes,
+      @Assisted boolean notifyListeners,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -136,6 +231,7 @@
     this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = null;
     this.indexes = indexes;
+    this.notifyListeners = notifyListeners;
     this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
@@ -262,19 +358,27 @@
   }
 
   private void fireChangeScheduledForIndexingEvent(String projectName, int id) {
-    indexedListeners.runEach(l -> l.onChangeScheduledForIndexing(projectName, id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeScheduledForIndexing(projectName, id));
+    }
   }
 
   private void fireChangeIndexedEvent(String projectName, int id) {
-    indexedListeners.runEach(l -> l.onChangeIndexed(projectName, id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeIndexed(projectName, id));
+    }
   }
 
   private void fireChangeScheduledForDeletionFromIndexEvent(int id) {
-    indexedListeners.runEach(l -> l.onChangeScheduledForDeletionFromIndex(id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeScheduledForDeletionFromIndex(id));
+    }
   }
 
   private void fireChangeDeletedFromIndexEvent(int id) {
-    indexedListeners.runEach(l -> l.onChangeDeleted(id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeDeleted(id));
+    }
   }
 
   /**
@@ -350,7 +454,7 @@
    * @param id ID of the change to index.
    * @return future for reindexing the change; returns true if the change was stale.
    */
-  public ListenableFuture<Boolean> reindexIfStale(Project.NameKey project, Change.Id id) {
+  public ListenableFuture<Boolean> asyncReindexIfStale(Project.NameKey project, Change.Id id) {
     ReindexIfStaleTask task = new ReindexIfStaleTask(project, id);
     if (queuedReindexIfStaleTasks.add(task)) {
       return submit(task, batchExecutor);
@@ -358,6 +462,41 @@
     return Futures.immediateFuture(false);
   }
 
+  /**
+   * Synchronously check if a change is stale, and reindex if it is.
+   *
+   * @param cd the change data to be checked for staleness.
+   * @return true if the change was stale, false if it was up-to-date
+   */
+  public boolean reindexIfStale(ChangeData cd) {
+    return reindexIfStale(cd.project(), cd.getId());
+  }
+
+  /**
+   * Synchronously check if a change is stale, and reindex if it is.
+   *
+   * @param project the project to which the change belongs.
+   * @param id ID of the change to index.
+   * @return true if the change was stale, false if it was up-to-date
+   */
+  public boolean reindexIfStale(Project.NameKey project, Change.Id id) {
+    try {
+      StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
+      if (stalenessCheckResult.isStale()) {
+        logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
+        indexImpl(changeDataFactory.create(project, id));
+        return true;
+      }
+    } catch (Exception e) {
+      if (!isCausedByRepositoryNotFoundException(e)) {
+        throw e;
+      }
+      logger.atFine().log(
+          "Change %s belongs to deleted project %s, aborting reindexing the change.", id, project);
+    }
+    return false;
+  }
+
   private void autoReindexIfStale(ChangeData cd) {
     autoReindexIfStale(cd.project(), cd.getId());
   }
@@ -366,7 +505,7 @@
     if (autoReindexIfStale) {
       // Don't retry indefinitely; if this fails the change will be stale.
       @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError = reindexIfStale(project, id);
+      Future<?> possiblyIgnoredError = asyncReindexIfStale(project, id);
     }
   }
 
@@ -546,22 +685,7 @@
     @Override
     public Boolean callImpl() throws Exception {
       remove();
-      try {
-        StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
-        if (stalenessCheckResult.isStale()) {
-          logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
-          indexImpl(changeDataFactory.create(project, id));
-          return true;
-        }
-      } catch (Exception e) {
-        if (!isCausedByRepositoryNotFoundException(e)) {
-          throw e;
-        }
-        logger.atFine().log(
-            "Change %s belongs to deleted project %s, aborting reindexing the change.",
-            id.get(), project.get());
-      }
-      return false;
+      return reindexIfStale(project, id);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 3d48907..4921b3f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -259,8 +259,15 @@
           .build();
 
   /** Upgrade Lucene to 9.x requires reindexing. */
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V85 = schema(V84);
+  @Deprecated static final Schema<ChangeData> V85 = schema(V84);
+
+  /** Add ChangeNumber field */
+  static final Schema<ChangeData> V86 =
+      new Schema.Builder<ChangeData>()
+          .add(V85)
+          .addIndexedFields(ChangeField.CHANGENUM_FIELD)
+          .addSearchSpecs(ChangeField.CHANGENUM_SPEC)
+          .build();
 
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index eb4af01..83f6189 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -36,8 +37,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
@@ -50,34 +51,58 @@
  * Checker that compares values stored in the change index to metadata in NoteDb to detect index
  * documents that should have been updated (= stale).
  */
-@Singleton
 public class StalenessChecker {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  public interface Factory {
+    StalenessChecker create(ChangeIndex index);
+  }
+
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
           ChangeField.CHANGE_SPEC.getName(),
           ChangeField.REF_STATE_SPEC.getName(),
           ChangeField.REF_STATE_PATTERN_SPEC.getName());
 
-  private final ChangeIndexCollection indexes;
+  @Nullable private final ChangeIndexCollection indexes;
+  @Nullable private final ChangeIndex index;
   private final GitRepositoryManager repoManager;
   private final IndexConfig indexConfig;
 
-  @Inject
-  StalenessChecker(
+  public StalenessChecker(
       ChangeIndexCollection indexes, GitRepositoryManager repoManager, IndexConfig indexConfig) {
     this.indexes = indexes;
+    this.index = null;
     this.repoManager = repoManager;
     this.indexConfig = indexConfig;
   }
 
+  @AssistedInject
+  StalenessChecker(
+      GitRepositoryManager repoManager, IndexConfig indexConfig, @Assisted ChangeIndex index) {
+    this.indexes = null;
+    this.repoManager = repoManager;
+    this.indexConfig = indexConfig;
+    this.index = index;
+  }
+
   /**
    * Returns a {@link StalenessCheckResult} with structured information about staleness of the
    * provided {@link com.google.gerrit.entities.Change.Id}.
    */
   public StalenessCheckResult check(Change.Id id) {
-    ChangeIndex i = indexes.getSearchIndex();
+    if (index != null) {
+      return check(id, index);
+    }
+    return check(id, indexes.getSearchIndex());
+  }
+
+  /**
+   * Returns a {@link StalenessCheckResult} with structured information about staleness of the
+   * provided {@link com.google.gerrit.entities.Change.Id} in the provided {@link
+   * com.google.gerrit.server.index.change.ChangeIndex}.
+   */
+  private StalenessCheckResult check(Change.Id id, ChangeIndex i) {
     if (i == null) {
       return StalenessCheckResult
           .notStale(); // No index; caller couldn't do anything if it is stale.
diff --git a/java/com/google/gerrit/server/index/scheduler/PeriodicIndexScheduler.java b/java/com/google/gerrit/server/index/scheduler/PeriodicIndexScheduler.java
new file mode 100644
index 0000000..9a0904b
--- /dev/null
+++ b/java/com/google/gerrit/server/index/scheduler/PeriodicIndexScheduler.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.scheduler;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.group.PeriodicGroupIndexer;
+import com.google.gerrit.server.project.PeriodicProjectIndexer;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+import com.google.inject.TypeLiteral;
+import java.util.HashMap;
+import java.util.Map;
+
+public class PeriodicIndexScheduler implements LifecycleListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(PeriodicIndexScheduler.class);
+      bind(new TypeLiteral<Map<String, PeriodicIndexerConfig>>() {})
+          .toProvider(PeriodicIndexerConfigProvider.class)
+          .in(Scopes.SINGLETON);
+    }
+  }
+
+  private final Map<String, PeriodicIndexerConfig> indexerConfigs;
+  private final WorkQueue queue;
+  private final Map<String, Runnable> indexers = new HashMap<>();
+
+  @Inject
+  PeriodicIndexScheduler(
+      Map<String, PeriodicIndexerConfig> indexerConfigs,
+      WorkQueue queue,
+      PeriodicGroupIndexer groupIndexer,
+      PeriodicProjectIndexer projectIndexer) {
+    this.indexerConfigs = indexerConfigs;
+    this.queue = queue;
+    indexers.put("groups", groupIndexer);
+    indexers.put("projects", projectIndexer);
+  }
+
+  @Override
+  public void start() {
+    for (Map.Entry<String, Runnable> e : indexers.entrySet()) {
+      String indexName = e.getKey();
+      if (indexerConfigs.containsKey(indexName)) {
+        Runnable indexer = e.getValue();
+        PeriodicIndexerConfig config = indexerConfigs.get(indexName);
+
+        if (config.runOnStartup()) {
+          indexer.run();
+        }
+
+        if (!config.enabled()) {
+          logger.atWarning().log("periodic reindexing for %s is disabled", indexName);
+          continue;
+        }
+
+        queue.scheduleAtFixedRate(indexer, config.schedule());
+      }
+    }
+  }
+
+  @Override
+  public void stop() {
+    // handled by WorkQueue.stop() already
+  }
+}
diff --git a/java/com/google/gerrit/server/index/scheduler/PeriodicIndexerConfig.java b/java/com/google/gerrit/server/index/scheduler/PeriodicIndexerConfig.java
new file mode 100644
index 0000000..6f0336b
--- /dev/null
+++ b/java/com/google/gerrit/server/index/scheduler/PeriodicIndexerConfig.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.scheduler;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.config.ScheduleConfig;
+
+@AutoValue
+public abstract class PeriodicIndexerConfig {
+
+  public static PeriodicIndexerConfig create(
+      boolean runOnStartup, boolean enabled, ScheduleConfig.Schedule schedule) {
+    return new AutoValue_PeriodicIndexerConfig(runOnStartup, enabled, schedule);
+  }
+
+  public abstract boolean runOnStartup();
+
+  public abstract boolean enabled();
+
+  public abstract ScheduleConfig.Schedule schedule();
+}
diff --git a/java/com/google/gerrit/server/index/scheduler/PeriodicIndexerConfigProvider.java b/java/com/google/gerrit/server/index/scheduler/PeriodicIndexerConfigProvider.java
new file mode 100644
index 0000000..16115eb
--- /dev/null
+++ b/java/com/google/gerrit/server/index/scheduler/PeriodicIndexerConfigProvider.java
@@ -0,0 +1,70 @@
+package com.google.gerrit.server.index.scheduler;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.GerritIsReplica;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+public class PeriodicIndexerConfigProvider implements Provider<Map<String, PeriodicIndexerConfig>> {
+
+  public static final Schedule DEFAULT_SCHEDULE =
+      Schedule.createOrFail(TimeUnit.MINUTES.toMillis(5), "00:00");
+
+  private static final String ENABLED = "enabled";
+  private static final String INDEX = "index";
+  private static final String RUN_ON_STARTUP = "runOnStartup";
+  private static final String SCHEDULED_INDEXER = "scheduledIndexer";
+
+  private final Config cfg;
+  private final Collection<IndexDefinition<?, ?, ?>> defs;
+  private final boolean isReplica;
+
+  @Inject
+  PeriodicIndexerConfigProvider(
+      @GerritServerConfig Config cfg,
+      Collection<IndexDefinition<?, ?, ?>> defs,
+      @GerritIsReplica boolean isReplica) {
+    this.cfg = cfg;
+    this.defs = defs;
+    this.isReplica = isReplica;
+  }
+
+  @Override
+  public Map<String, PeriodicIndexerConfig> get() {
+    ImmutableMap.Builder<String, PeriodicIndexerConfig> builder =
+        ImmutableMap.<String, PeriodicIndexerConfig>builder();
+    Set<String> scheduledIndexerSubsections = cfg.getSubsections(SCHEDULED_INDEXER);
+
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      String indexName = def.getName();
+      if (scheduledIndexerSubsections.contains(indexName)) {
+        builder.put(indexName, parse(SCHEDULED_INDEXER, indexName, false, false));
+      } else if ("groups".equals(indexName) && isReplica) {
+        builder.put(indexName, parse(INDEX, SCHEDULED_INDEXER, true, true));
+      }
+    }
+
+    return builder.build();
+  }
+
+  private PeriodicIndexerConfig parse(
+      String section, String subsection, boolean defaultRunOnStartup, boolean defaultEnabled) {
+    boolean runOnStartup = cfg.getBoolean(section, subsection, RUN_ON_STARTUP, defaultRunOnStartup);
+    boolean enabled = cfg.getBoolean(section, subsection, ENABLED, defaultEnabled);
+    Schedule schedule =
+        ScheduleConfig.builder(cfg, section)
+            .setSubsection(subsection)
+            .buildSchedule()
+            .orElse(DEFAULT_SCHEDULE);
+    return PeriodicIndexerConfig.create(runOnStartup, enabled, schedule);
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/CallerFinder.java b/java/com/google/gerrit/server/logging/CallerFinder.java
deleted file mode 100644
index a6c2131..0000000
--- a/java/com/google/gerrit/server/logging/CallerFinder.java
+++ /dev/null
@@ -1,275 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.logging;
-
-import static com.google.common.flogger.LazyArgs.lazy;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.LazyArg;
-import java.util.Optional;
-
-/**
- * Utility to compute the caller of a method.
- *
- * <p>In the logs we see for each entry from where it was triggered (class/method/line) but in case
- * the logging is done in a utility method or inside of a module this doesn't tell us from where the
- * action was actually triggered. To get this information we could included the stacktrace into the
- * logs (by calling {@link
- * com.google.common.flogger.LoggingApi#withStackTrace(com.google.common.flogger.StackSize)} but
- * sometimes there are too many uninteresting stacks so that this would blow up the logs too much.
- * In this case CallerFinder can be used to find the first interesting caller from the current
- * stacktrace by specifying the class that interesting callers invoke as target.
- *
- * <p>Example:
- *
- * <p>Index queries are executed by the {@code query(List<String>, List<Predicate<T>>)} method in
- * {@link com.google.gerrit.index.query.QueryProcessor}. At this place the index query is logged but
- * from the log we want to see which code triggered this index query.
- *
- * <p>E.g. the stacktrace could look like this:
- *
- * <pre>{@code
- * GroupQueryProcessor(QueryProcessor<T>).query(List<String>, List<Predicate<T>>) line: 216
- * GroupQueryProcessor(QueryProcessor<T>).query(List<Predicate<T>>) line: 188
- * GroupQueryProcessor(QueryProcessor<T>).query(Predicate<T>) line: 171
- * InternalGroupQuery(InternalQuery<T>).query(Predicate<T>) line: 81
- * InternalGroupQuery.getOnlyGroup(Predicate<InternalGroup>, String) line: 67
- * InternalGroupQuery.byName(NameKey) line: 50
- * GroupCacheImpl$ByNameLoader.load(String) line: 166
- * GroupCacheImpl$ByNameLoader.load(Object) line: 1
- * LocalCache$LoadingValueReference<K,V>.loadFuture(K, CacheLoader<? super K,V>) line: 3527
- * ...
- * }</pre>
- *
- * <p>The first interesting caller is {@code GroupCacheImpl$ByNameLoader.load(String) line: 166}. To
- * find this caller from the stacktrace we could specify {@link
- * com.google.gerrit.server.query.group.InternalGroupQuery} as a target since we know that all
- * internal group queries go through this class:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(InternalGroupQuery.class)
- *   .build();
- * </pre>
- *
- * <p>Since in some places {@link com.google.gerrit.server.query.group.GroupQueryProcessor} may also
- * be used directly we can add it as a secondary target to catch these callers as well:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(InternalGroupQuery.class)
- *   .addTarget(GroupQueryProcessor.class)
- *   .build();
- * </pre>
- *
- * <p>However since {@link com.google.gerrit.index.query.QueryProcessor} is also responsible to
- * execute other index queries (for changes, accounts, projects) we would need to add the classes
- * for them as targets too. Since there are common base classes we can simply specify the base
- * classes and request matching of subclasses:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(InternalQuery.class)
- *   .addTarget(QueryProcessor.class)
- *   .matchSubClasses(true)
- *   .build();
- * </pre>
- *
- * <p>Another special case is if the entry point is always an inner class of a known interface. E.g.
- * {@link com.google.gerrit.server.permissions.PermissionBackend} is the entry point for all
- * permission checks but they are done through inner classes, e.g. {@link
- * com.google.gerrit.server.permissions.PermissionBackend.ForProject}. In this case matching of
- * inner classes must be enabled as well:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(PermissionBackend.class)
- *   .matchSubClasses(true)
- *   .matchInnerClasses(true)
- *   .build();
- * </pre>
- *
- * <p>Finding the interesting caller requires specifying the entry point class as target. This may
- * easily break when code is refactored and hence should be used only with care. It's recommended to
- * use this only when the corresponding code is relatively stable and logging the caller information
- * brings some significant benefit.
- *
- * <p>Based on {@link com.google.common.flogger.util.CallerFinder}.
- */
-@AutoValue
-public abstract class CallerFinder {
-  public static Builder builder() {
-    return new AutoValue_CallerFinder.Builder()
-        .matchSubClasses(false)
-        .matchInnerClasses(false)
-        .skip(0);
-  }
-
-  /**
-   * The target classes for which the caller should be found, in the order in which they should be
-   * checked.
-   *
-   * @return the target classes for which the caller should be found
-   */
-  public abstract ImmutableList<Class<?>> targets();
-
-  /**
-   * Whether inner classes should be matched.
-   *
-   * @return whether inner classes should be matched
-   */
-  public abstract boolean matchSubClasses();
-
-  /**
-   * Whether sub classes of the target classes should be matched.
-   *
-   * @return whether sub classes of the target classes should be matched
-   */
-  public abstract boolean matchInnerClasses();
-
-  /**
-   * The minimum number of calls known to have occurred between the first call to the target class
-   * and the call of {@link #findCallerLazy()}. If in doubt, specify zero here to avoid accidentally
-   * skipping past the caller.
-   *
-   * @return the number of stack elements to skip when computing the caller
-   */
-  public abstract int skip();
-
-  /**
-   * Packages that should be ignored and not be considered as caller once a target has been found.
-   *
-   * @return the ignored packages
-   */
-  public abstract ImmutableList<String> ignoredPackages();
-
-  /**
-   * Classes that should be ignored and not be considered as caller once a target has been found.
-   *
-   * @return the qualified names of the ignored classes
-   */
-  public abstract ImmutableList<String> ignoredClasses();
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    abstract ImmutableList.Builder<Class<?>> targetsBuilder();
-
-    public Builder addTarget(Class<?> target) {
-      targetsBuilder().add(target);
-      return this;
-    }
-
-    public abstract Builder matchSubClasses(boolean matchSubClasses);
-
-    public abstract Builder matchInnerClasses(boolean matchInnerClasses);
-
-    public abstract Builder skip(int skip);
-
-    abstract ImmutableList.Builder<String> ignoredPackagesBuilder();
-
-    public Builder addIgnoredPackage(String ignoredPackage) {
-      ignoredPackagesBuilder().add(ignoredPackage);
-      return this;
-    }
-
-    abstract ImmutableList.Builder<String> ignoredClassesBuilder();
-
-    public Builder addIgnoredClass(Class<?> ignoredClass) {
-      ignoredClassesBuilder().add(ignoredClass.getName());
-      return this;
-    }
-
-    public abstract CallerFinder build();
-  }
-
-  public String findCaller() {
-    return targets().stream()
-        .map(t -> findCallerOf(t, skip() + 1))
-        .filter(Optional::isPresent)
-        .findFirst()
-        .map(Optional::get)
-        .orElse("unknown");
-  }
-
-  public LazyArg<String> findCallerLazy() {
-    return lazy(() -> findCaller());
-  }
-
-  private Optional<String> findCallerOf(Class<?> target, int skip) {
-    // Skip one additional stack frame because we create the Throwable inside this method, not at
-    // the point that this method was invoked.
-    skip++;
-
-    StackTraceElement[] stack = new Throwable().getStackTrace();
-
-    // Note: To avoid having to reflect the getStackTraceDepth() method as well, we assume that we
-    // will find the caller on the stack and simply catch an exception if we fail (which should
-    // hardly ever happen).
-    boolean foundCaller = false;
-    try {
-      for (int index = skip; ; index++) {
-        StackTraceElement element = stack[index];
-        if (isCaller(target, element.getClassName(), matchSubClasses())) {
-          foundCaller = true;
-        } else if (foundCaller
-            && !ignoredPackages().contains(getPackageName(element))
-            && !ignoredClasses().contains(element.getClassName())) {
-          return Optional.of(element.toString());
-        }
-      }
-    } catch (Exception e) {
-      // This should only happen if a) the caller was not found on the stack
-      // (IndexOutOfBoundsException) b) a class that is mentioned in the stack was not found
-      // (ClassNotFoundException), however we don't want anything to be thrown from here.
-      return Optional.empty();
-    }
-  }
-
-  private static String getPackageName(StackTraceElement element) {
-    String className = element.getClassName();
-    return className.substring(0, className.lastIndexOf("."));
-  }
-
-  private boolean isCaller(Class<?> target, String className, boolean matchSubClasses)
-      throws ClassNotFoundException {
-    if (matchSubClasses) {
-      Class<?> clazz = Class.forName(className);
-      while (clazz != null) {
-        if (Object.class.getName().equals(clazz.getName())) {
-          break;
-        }
-
-        if (isCaller(target, clazz.getName(), false)) {
-          return true;
-        }
-        clazz = clazz.getSuperclass();
-      }
-    }
-
-    if (matchInnerClasses()) {
-      int i = className.indexOf('$');
-      if (i > 0) {
-        className = className.substring(0, i);
-      }
-    }
-
-    if (target.getName().equals(className)) {
-      return true;
-    }
-
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index b7f3404..33a49ae 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -14,19 +14,13 @@
 
 package com.google.gerrit.server.logging;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.MoreObjects.ToStringHelper;
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.LazyArg;
-import com.google.common.flogger.LazyArgs;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.util.Arrays;
-import java.util.Comparator;
 import java.util.Optional;
 
 /** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */
@@ -187,8 +181,7 @@
   public abstract Optional<String> username();
 
   /**
-   * Returns a string representation of this instance that is suitable for logging. This is wrapped
-   * in a {@link LazyArg} because it is expensive to evaluate.
+   * Returns a string representation of this instance that is suitable for logging.
    *
    * <p>{@link #toString()} formats the {@link Optional} fields as {@code key=Optional[value]} or
    * {@code key=Optional.empty}. Since this class has many optional fields from which usually only a
@@ -221,72 +214,68 @@
    * <p>For the example given above the formatted string would look like this:
    *
    * <pre>
-   * Metadata{changeId=9212550, indexVersion=0, pluginMetadata=[]}
+   * Metadata{changeId=9212550, indexVersion=0}
    * </pre>
    *
    * @return string representation of this instance that is suitable for logging
    */
-  LazyArg<String> toStringForLoggingLazy() {
-    // Don't use a lambda because different compilers generate different method names for lambdas,
-    // e.g. "lambda$myFunction$0" vs. just "lambda$0" in Eclipse. We need to identify the method
-    // by name to skip it and avoid infinite recursion.
-    return LazyArgs.lazy(this::toStringForLoggingImpl);
-  }
-
-  private String toStringForLoggingImpl() {
-    // Append class name.
-    String className = getClass().getSimpleName();
-    if (className.startsWith("AutoValue_")) {
-      className = className.substring(10);
-    }
-    ToStringHelper stringHelper = MoreObjects.toStringHelper(className);
-
-    // Append key-value pairs for field which are set.
-    Method[] methods = Metadata.class.getDeclaredMethods();
-    Arrays.sort(methods, Comparator.comparing(Method::getName));
-    for (Method method : methods) {
-      if (Modifier.isStatic(method.getModifiers())) {
-        // skip static method
-        continue;
-      }
-
-      if (method.getName().equals("toStringForLoggingLazy")
-          || method.getName().equals("toStringForLoggingImpl")) {
-        // Don't call myself in infinite recursion.
-        continue;
-      }
-
-      if (method.getReturnType().equals(Void.TYPE) || method.getParameterCount() > 0) {
-        // skip method since it's not a getter
-        continue;
-      }
-
-      method.setAccessible(true);
-
-      Object returnValue;
-      try {
-        returnValue = method.invoke(this);
-      } catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) {
-        // should never happen
-        throw new IllegalStateException(e);
-      }
-
-      if (returnValue instanceof Optional) {
-        Optional<?> fieldValueOptional = (Optional<?>) returnValue;
-        if (!fieldValueOptional.isPresent()) {
-          // drop this key-value pair
-          continue;
-        }
-
-        // format as 'key=value' instead of 'key=Optional[value]'
-        stringHelper.add(method.getName(), fieldValueOptional.get());
-      } else {
-        // not an Optional value, keep as is
-        stringHelper.add(method.getName(), returnValue);
-      }
-    }
-
-    return stringHelper.toString();
+  public String toStringForLogging() {
+    return MoreObjects.toStringHelper("Metadata")
+        .omitNullValues()
+        .add("accountId", accountId().orElse(null))
+        .add("actionType", actionType().orElse(null))
+        .add("attempt", attempt().orElse(null))
+        .add("authDomainName", authDomainName().orElse(null))
+        .add("branchName", branchName().orElse(null))
+        .add("cacheKey", cacheKey().orElse(null))
+        .add("cacheName", cacheName().orElse(null))
+        .add("caller", caller().orElse(null))
+        .add("className", className().orElse(null))
+        .add("cancellationReason", cancellationReason().orElse(null))
+        .add("changeId", changeId().orElse(null))
+        .add("changeIdType", changeIdType().orElse(null))
+        .add("cause", cause().orElse(null))
+        .add("commentSide", commentSide().orElse(null))
+        .add("commit", commit().orElse(null))
+        .add("diffAlgorithm", diffAlgorithm().orElse(null))
+        .add("eventType", eventType().orElse(null))
+        .add("exportValue", exportValue().orElse(null))
+        .add("filePath", filePath().orElse(null))
+        .add("garbageCollectorName", garbageCollectorName().orElse(null))
+        .add("gitOperation", gitOperation().orElse(null))
+        .add("groupId", groupId().orElse(null))
+        .add("groupName", groupName().orElse(null))
+        .add("groupSystem", groupSystem().orElse(null))
+        .add("groupUuid", groupUuid().orElse(null))
+        .add("httpStatus", httpStatus().orElse(null))
+        .add("indexName", indexName().orElse(null))
+        .add("memoryPoolName", memoryPoolName().orElse(null))
+        .add("methodName", methodName().orElse(null))
+        .add("multiple", multiple().orElse(null))
+        .add("operationName", operationName().orElse(null))
+        .add("partial", partial().orElse(null))
+        .add("outdated", outdated().orElse(null))
+        .add("noteDbFilePath", noteDbFilePath().orElse(null))
+        .add("noteDbRefName", noteDbRefName().orElse(null))
+        .add("noteDbSequenceType", noteDbSequenceType().orElse(null))
+        .add("patchSetId", patchSetId().orElse(null))
+        .add(
+            "pluginMetadata",
+            !pluginMetadata().isEmpty()
+                ? pluginMetadata().stream()
+                    .map(PluginMetadata::toStringForLogging)
+                    .collect(toImmutableList())
+                : null)
+        .add("pluginName", pluginName().orElse(null))
+        .add("projectName", projectName().orElse(null))
+        .add("pushType", pushType().orElse(null))
+        .add("requestType", requestType().orElse(null))
+        .add("resourceCount", resourceCount().orElse(null))
+        .add("restViewName", restViewName().orElse(null))
+        .add("submitRequirementName", submitRequirementName().orElse(null))
+        .add("revision", revision().orElse(null))
+        .add("username", username().orElse(null))
+        .toString();
   }
 
   public static Metadata.Builder builder() {
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index 90e716f..8cf5b84 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -92,6 +92,7 @@
             p -> {
               try (TraceContext traceContext = newPluginTrace(p)) {
                 performanceLogRecords.forEach(r -> r.writeTo(p.get()));
+                p.get().done();
               } catch (RuntimeException e) {
                 logger.atWarning().withCause(e).log(
                     "Failure in %s of plugin %s", p.get().getClass(), p.getPluginName());
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
index 046eeb3..2f6c420 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.auto.value.AutoValue;
+import java.time.Instant;
 import java.util.Optional;
 
 /**
@@ -29,36 +30,41 @@
    * Creates a performance log record without meta data.
    *
    * @param operation the name of operation the is was performed
-   * @param durationMs the execution time in milliseconds
+   * @param durationNanos the execution time in nanoseconds
    * @return the performance log record
    */
-  public static PerformanceLogRecord create(String operation, long durationMs) {
-    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.empty());
+  public static PerformanceLogRecord create(String operation, long durationNanos) {
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationNanos, Instant.now(), Optional.empty());
   }
 
   /**
    * Creates a performance log record with meta data.
    *
    * @param operation the name of operation the is was performed
-   * @param durationMs the execution time in milliseconds
+   * @param durationNanos the execution time in nanoseconds
    * @param metadata metadata
    * @return the performance log record
    */
-  public static PerformanceLogRecord create(String operation, long durationMs, Metadata metadata) {
-    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.of(metadata));
+  public static PerformanceLogRecord create(
+      String operation, long durationNanos, Metadata metadata) {
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationNanos, Instant.now(), Optional.of(metadata));
   }
 
   public abstract String operation();
 
-  public abstract long durationMs();
+  public abstract long durationNanos();
+
+  public abstract Instant endTime();
 
   public abstract Optional<Metadata> metadata();
 
   void writeTo(PerformanceLogger performanceLogger) {
     if (metadata().isPresent()) {
-      performanceLogger.log(operation(), durationMs(), metadata().get());
+      performanceLogger.logNanos(operation(), durationNanos(), endTime(), metadata().get());
     } else {
-      performanceLogger.log(operation(), durationMs());
+      performanceLogger.logNanos(operation(), durationNanos(), endTime());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
index 74a1684..02fb37c 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogger.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.time.Instant;
 
 /**
  * Extension point for logging performance records.
@@ -33,18 +34,24 @@
    * Record the execution time of an operation in a performance log.
    *
    * @param operation operation that was performed
-   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param durationNanos time that the execution of the operation took (in nanoseconds)
    */
-  default void log(String operation, long durationMs) {
-    log(operation, durationMs, Metadata.empty());
+  default void logNanos(String operation, long durationNanos, Instant endTime) {
+    logNanos(operation, durationNanos, endTime, Metadata.empty());
   }
 
   /**
    * Record the execution time of an operation in a performance log.
    *
    * @param operation operation that was performed
-   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param durationNanos time that the execution of the operation took (in nanoseconds)
    * @param metadata metadata
    */
-  void log(String operation, long durationMs, Metadata metadata);
+  void logNanos(String operation, long durationNanos, Instant endTime, Metadata metadata);
+
+  /**
+   * Called after all performance events of a request have been logged via {@link #logNanos(String,
+   * long, Instant)} or {@link #logNanos(String, long, Instant, Metadata)}.
+   */
+  default void done() {}
 }
diff --git a/java/com/google/gerrit/server/logging/PluginMetadata.java b/java/com/google/gerrit/server/logging/PluginMetadata.java
index 21f7359..590ced4 100644
--- a/java/com/google/gerrit/server/logging/PluginMetadata.java
+++ b/java/com/google/gerrit/server/logging/PluginMetadata.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
 
@@ -36,4 +37,8 @@
   public abstract String key();
 
   public abstract Optional<String> value();
+
+  public String toStringForLogging() {
+    return MoreObjects.toStringHelper("PluginMetadata").add(key(), value().orElse(null)).toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index fb698f7..3213422 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -187,11 +187,12 @@
 
     private TraceTimer(String operation) {
       this(
-          () -> logger.atFine().log("Starting timer for %s", operation),
-          elapsedMs -> {
+          () -> logger.atFine().log("Starting timer %s", operation),
+          elapsedNanos -> {
             LoggingContext.getInstance()
-                .addPerformanceLogRecord(() -> PerformanceLogRecord.create(operation, elapsedMs));
-            logger.atFine().log("%s done (%d ms)", operation, elapsedMs);
+                .addPerformanceLogRecord(
+                    () -> PerformanceLogRecord.create(operation, elapsedNanos));
+            logger.atFine().log("timer %s took %.2f ms", operation, elapsedNanos / 1000000.0);
           });
     }
 
@@ -199,13 +200,14 @@
       this(
           () ->
               logger.atFine().log(
-                  "Starting timer for %s (%s)", operation, metadata.toStringForLoggingLazy()),
-          elapsedMs -> {
+                  "Starting timer %s (%s)", operation, metadata.toStringForLogging()),
+          elapsedNanos -> {
             LoggingContext.getInstance()
                 .addPerformanceLogRecord(
-                    () -> PerformanceLogRecord.create(operation, elapsedMs, metadata));
+                    () -> PerformanceLogRecord.create(operation, elapsedNanos, metadata));
             logger.atFine().log(
-                "%s (%s) done (%d ms)", operation, metadata.toStringForLoggingLazy(), elapsedMs);
+                "timer %s (%s) took %.2f ms",
+                operation, metadata.toStringForLogging(), elapsedNanos / 1000000.0);
           });
     }
 
@@ -219,7 +221,7 @@
     @Override
     public void close() {
       stopwatch.stop();
-      doneLogFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+      doneLogFn.accept(stopwatch.elapsed(TimeUnit.NANOSECONDS));
       RequestStateContext.abortIfCancelled();
     }
   }
diff --git a/java/com/google/gerrit/server/mail/EmailFactories.java b/java/com/google/gerrit/server/mail/EmailFactories.java
index 378fb34..036f21d5 100644
--- a/java/com/google/gerrit/server/mail/EmailFactories.java
+++ b/java/com/google/gerrit/server/mail/EmailFactories.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.client.ChangeKind;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -66,6 +66,45 @@
   String INBOUND_EMAIL_REJECTED = "error";
   String NEW_EMAIL_REGISTERED = "registernewemail";
 
+  public static String messageClassDisplay(String messageClass) {
+    switch (messageClass) {
+      case CHANGE_ABANDONED:
+        return "Abandoned";
+      case ATTENTION_SET_ADDED:
+        return "Added to Attention Set";
+      case ATTENTION_SET_REMOVED:
+        return "Removed from Attention Set";
+      case COMMENTS_ADDED:
+        return "Comments";
+      case REVIEWER_DELETED:
+        return "Reviewer Deleted";
+      case VOTE_DELETED:
+        return "Vote Deleted";
+      case CHANGE_MERGED:
+        return "Merged";
+      case NEW_PATCHSET_ADDED:
+        return "New Patchset";
+      case CHANGE_RESTORED:
+        return "Restored";
+      case CHANGE_REVERTED:
+        return "Reverted";
+      case REVIEW_REQUESTED:
+        return "Review Request";
+      case KEY_ADDED:
+        return "Key Added";
+      case KEY_DELETED:
+        return "Key Deleted";
+      case PASSWORD_UPDATED:
+        return "Password Updated";
+      case INBOUND_EMAIL_REJECTED:
+        return "Error";
+      case NEW_EMAIL_REGISTERED:
+        return "Email Registered";
+      default:
+        return messageClass;
+    }
+  }
+
   /** ChangeEmail decorator that adds information about change being abandoned to the email. */
   ChangeEmailDecorator createAbandonedChangeEmail();
 
@@ -86,7 +125,8 @@
   ChangeEmailDecorator createDeleteVoteChangeEmail();
 
   /** ChangeEmail decorator that adds information about change being merged to the email. */
-  ChangeEmailDecorator createMergedChangeEmail(Optional<String> stickyApprovalDiff);
+  ChangeEmailDecorator createMergedChangeEmail(
+      Optional<String> stickyApprovalDiff, List<FileDiffOutput> modifiedFiles);
 
   /** ChangeEmail decorator that adds information about a new patchset added to the change. */
   ReplacePatchSetChangeEmailDecorator createReplacePatchSetChangeEmail(
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index c411af5..0acb8e3 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -23,8 +23,8 @@
 
 @Singleton
 public class EmailSettings {
-  private static final String SEND_EMAL = "sendemail";
-  private static final String RECEIVE_EMAL = "receiveemail";
+  private static final String SEND_EMAIL = "sendemail";
+  private static final String RECEIVE_EMAIL = "receiveemail";
   // Send
   public final boolean html;
   public final boolean includeDiff;
@@ -38,27 +38,29 @@
   public final Encryption encryption;
   public final long fetchInterval; // in milliseconds
   public final boolean sendNewPatchsetEmails;
+  public final boolean includeThreadIndexHeader;
 
   @Inject
   EmailSettings(@GerritServerConfig Config cfg) {
     // Send
-    html = cfg.getBoolean(SEND_EMAL, "html", true);
-    includeDiff = cfg.getBoolean(SEND_EMAL, "includeDiff", false);
-    maximumDiffSize = cfg.getInt(SEND_EMAL, "maximumDiffSize", 256 << 10);
+    html = cfg.getBoolean(SEND_EMAIL, "html", true);
+    includeDiff = cfg.getBoolean(SEND_EMAIL, "includeDiff", false);
+    maximumDiffSize = cfg.getInt(SEND_EMAIL, "maximumDiffSize", 256 << 10);
     // Receive
-    protocol = cfg.getEnum(RECEIVE_EMAL, null, "protocol", Protocol.NONE);
-    host = cfg.getString(RECEIVE_EMAL, null, "host");
-    port = cfg.getInt(RECEIVE_EMAL, "port", 0);
-    username = cfg.getString(RECEIVE_EMAL, null, "username");
-    password = cfg.getString(RECEIVE_EMAL, null, "password");
-    encryption = cfg.getEnum(RECEIVE_EMAL, null, "encryption", Encryption.NONE);
+    protocol = cfg.getEnum(RECEIVE_EMAIL, null, "protocol", Protocol.NONE);
+    host = cfg.getString(RECEIVE_EMAIL, null, "host");
+    port = cfg.getInt(RECEIVE_EMAIL, "port", 0);
+    username = cfg.getString(RECEIVE_EMAIL, null, "username");
+    password = cfg.getString(RECEIVE_EMAIL, null, "password");
+    encryption = cfg.getEnum(RECEIVE_EMAIL, null, "encryption", Encryption.NONE);
     fetchInterval =
         cfg.getTimeUnit(
-            RECEIVE_EMAL,
+            RECEIVE_EMAIL,
             null,
             "fetchInterval",
             TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
     sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
+    includeThreadIndexHeader = cfg.getBoolean(SEND_EMAIL, null, "includeThreadIndexHeader", true);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index df30573..6c38210 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -252,7 +252,7 @@
           queryProvider
               .get()
               .enforceVisibility(true)
-              .byLegacyChangeId(Change.id(metadata.changeNumber));
+              .byChangeNumber(Change.id(metadata.changeNumber));
       if (changeDataList.isEmpty()) {
         sendRejectionEmail(message, InboundEmailError.CHANGE_NOT_FOUND);
         return;
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
index 388b0d0..9acc692 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -16,11 +16,13 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.factory.AutoFactory;
 import com.google.auto.factory.Provided;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -288,6 +290,10 @@
       email.setHeader("In-Reply-To", changeMessageThreadId);
     }
     email.setHeader("References", changeMessageThreadId);
+    if (args.settings.includeThreadIndexHeader) {
+      email.setHeader(
+          "Thread-Index", BaseEncoding.base64Url().encode(changeMessageThreadId.getBytes(UTF_8)));
+    }
   }
 
   /** Get the text of the "cover letter". */
diff --git a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
index ce586fc..6a56e38 100644
--- a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
+++ b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -99,8 +100,9 @@
   }
 
   @Override
-  public ChangeEmailDecorator createMergedChangeEmail(Optional<String> stickyApprovalDiff) {
-    return mergedChangeEmailFactory.create(stickyApprovalDiff);
+  public ChangeEmailDecorator createMergedChangeEmail(
+      Optional<String> stickyApprovalDiff, List<FileDiffOutput> modifiedFiles) {
+    return mergedChangeEmailFactory.create(stickyApprovalDiff, modifiedFiles);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index ecf808d..4c63453 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.MailUtil;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -41,12 +42,43 @@
 public class FromAddressGeneratorProvider implements Provider<FromAddressGenerator> {
   private final FromAddressGenerator generator;
 
+  public static class UserAddressGenModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(UserAddressGenFactory.class).to(DefaultUserAddressGenFactory.class);
+    }
+  }
+
+  /** A generic interface for creating user address generators. */
+  public interface UserAddressGenFactory {
+    FromAddressGenerator create(
+        AccountCache accountCache,
+        Pattern domainPattern,
+        String anonymousCowardName,
+        ParameterizedString nameRewriteTmpl,
+        Address serverAddress);
+  }
+
+  public static class DefaultUserAddressGenFactory implements UserAddressGenFactory {
+    @Override
+    public FromAddressGenerator create(
+        AccountCache accountCache,
+        Pattern domainPattern,
+        String anonymousCowardName,
+        ParameterizedString nameRewriteTmpl,
+        Address serverAddress) {
+      return new UserGen(
+          accountCache, domainPattern, anonymousCowardName, nameRewriteTmpl, serverAddress);
+    }
+  }
+
   @Inject
   FromAddressGeneratorProvider(
       @GerritServerConfig Config cfg,
       @AnonymousCowardName String anonymousCowardName,
       @GerritPersonIdent PersonIdent myIdent,
-      AccountCache accountCache) {
+      AccountCache accountCache,
+      UserAddressGenFactory userAddressGenFactory) {
     final String from = cfg.getString("sendemail", null, "from");
     final Address srvAddr = toAddress(myIdent);
 
@@ -58,7 +90,8 @@
       Pattern domainPattern = MailUtil.glob(domains);
       ParameterizedString namePattern = new ParameterizedString("${user} (Code Review)");
       generator =
-          new UserGen(accountCache, domainPattern, anonymousCowardName, namePattern, srvAddr);
+          userAddressGenFactory.create(
+              accountCache, domainPattern, anonymousCowardName, namePattern, srvAddr);
     } else if ("SERVER".equalsIgnoreCase(from)) {
       generator = new ServerGen(srvAddr);
     } else {
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
index af265a6..1feba6d 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
@@ -53,7 +53,7 @@
     email.addSoyEmailDataParam("email", getEmail());
     email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
     email.addSoyEmailDataParam("operation", operation);
-    email.addSoyEmailDataParam("httpPasswordSettingsUrl", email.getSettingsUrl("http-password"));
+    email.addSoyEmailDataParam("httpPasswordSettingsUrl", email.getSettingsUrl("HTTPCredentials"));
 
     email.appendText(email.textTemplate("HttpPasswordUpdate"));
     if (email.useHtml()) {
diff --git a/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
index 937d7a8..31376a7 100644
--- a/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
@@ -31,6 +31,8 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import java.util.List;
 import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
@@ -43,11 +45,18 @@
   protected LabelTypes labelTypes;
   protected final EmailArguments args;
   protected final Optional<String> stickyApprovalDiff;
+  // This is only used in google-internal override.
+  // It is helpful to keep this here, for bringing internal override into
+  // upstream later
+  protected final List<FileDiffOutput> modifiedFiles;
 
   public MergedChangeEmailDecorator(
-      @Provided EmailArguments args, Optional<String> stickyApprovalDiff) {
+      @Provided EmailArguments args,
+      Optional<String> stickyApprovalDiff,
+      List<FileDiffOutput> modifiedFiles) {
     this.args = args;
     this.stickyApprovalDiff = stickyApprovalDiff;
+    this.modifiedFiles = modifiedFiles;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
index b32c43a..29b914f 100644
--- a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -1,58 +1,26 @@
-// 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.mail.send;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.MailMessage;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.update.RepoView;
-import com.google.inject.Inject;
-import java.io.IOException;
 import java.time.Instant;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
 
-/** A generator class that creates a {@link MessageId} */
-public class MessageIdGenerator {
-  private final GitRepositoryManager repositoryManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public MessageIdGenerator(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
-    this.repositoryManager = repositoryManager;
-    this.allUsersName = allUsersName;
-  }
-
+public interface MessageIdGenerator {
   /**
    * A unique id used which is a part of the header of all emails sent through by Gerrit. All of the
    * emails are sent via {@link OutgoingEmail#send()}.
    */
   @AutoValue
-  public abstract static class MessageId {
+  abstract class MessageId {
     public abstract String id();
+
+    public static MessageId create(String id) {
+      return new AutoValue_MessageIdGenerator_MessageId(id);
+    }
   }
 
   /**
@@ -60,43 +28,19 @@
    *
    * @return MessageId that depends on the patchset.
    */
-  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
-    return fromChangeUpdateAndReason(repoView, patchsetId, null);
-  }
+  MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId);
 
-  public MessageId fromChangeUpdateAndReason(
-      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
-    String suffix = (reason != null) ? ("-" + reason) : "";
-    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
-    Optional<ObjectId> metaSha1;
-    try {
-      metaSha1 = repoView.getRef(metaRef);
-    } catch (IOException ex) {
-      throw new StorageException("unable to extract info for Message-Id", ex);
-    }
-    return metaSha1
-        .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName() + suffix))
-        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
-  }
+  MessageId fromChangeUpdateAndReason(
+      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason);
 
-  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
-    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
-    Ref ref = getRef(metaRef, project);
-    checkState(ref != null, metaRef + " must exist");
-    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
-  }
+  MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId);
 
   /**
    * Create a {@link MessageId} as a result of an account update
    *
    * @return {@link MessageId} that depends on the account id.
    */
-  public MessageId fromAccountUpdate(Account.Id accountId) {
-    String userRef = RefNames.refsUsers(accountId);
-    Ref ref = getRef(userRef, allUsersName);
-    checkState(ref != null, userRef + " must exist");
-    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
-  }
+  MessageId fromAccountUpdate(Account.Id accountId);
 
   /**
    * Create a {@link MessageId} from a mail message.
@@ -104,9 +48,7 @@
    * @param mailMessage The message that was sent but was rejected.
    * @return MessageId that depends on the MailMessage that was rejected.
    */
-  public MessageId fromMailMessage(MailMessage mailMessage) {
-    return new AutoValue_MessageIdGenerator_MessageId(mailMessage.id() + "-REJECTION");
-  }
+  MessageId fromMailMessage(MailMessage mailMessage);
 
   /**
    * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
@@ -114,17 +56,5 @@
    * @param reason for performing this account update
    * @return MessageId that depends on the reason, accountId, and timestamp.
    */
-  public MessageId fromReasonAccountIdAndTimestamp(
-      String reason, Account.Id accountId, Instant timestamp) {
-    return new AutoValue_MessageIdGenerator_MessageId(
-        reason + "-" + accountId.toString() + "-" + timestamp.toString());
-  }
-
-  private Ref getRef(String userRef, Project.NameKey project) {
-    try (Repository repository = repositoryManager.openRepository(project)) {
-      return repository.getRefDatabase().findRef(userRef);
-    } catch (IOException ex) {
-      throw new StorageException("unable to extract info for Message-Id", ex);
-    }
-  }
+  MessageId fromReasonAccountIdAndTimestamp(String reason, Account.Id accountId, Instant timestamp);
 }
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java b/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java
new file mode 100644
index 0000000..b67034f
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.checkState;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.RepoView;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * A generator class that creates a {@link
+ * com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId}
+ */
+public class MessageIdGeneratorImpl implements MessageIdGenerator {
+  private final GitRepositoryManager repositoryManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public MessageIdGeneratorImpl(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
+    this.repositoryManager = repositoryManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+    return fromChangeUpdateAndReason(repoView, patchsetId, null);
+  }
+
+  @Override
+  public MessageId fromChangeUpdateAndReason(
+      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
+    String suffix = (reason != null) ? ("-" + reason) : "";
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Optional<ObjectId> metaSha1;
+    try {
+      metaSha1 = repoView.getRef(metaRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+    return metaSha1
+        .map(optional -> MessageId.create(optional.getName() + suffix))
+        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
+  }
+
+  @Override
+  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Ref ref = getRef(metaRef, project);
+    checkState(ref != null, metaRef + " must exist");
+    return MessageId.create(ref.getObjectId().getName());
+  }
+
+  @Override
+  public MessageId fromAccountUpdate(Account.Id accountId) {
+    String userRef = RefNames.refsUsers(accountId);
+    Ref ref = getRef(userRef, allUsersName);
+    checkState(ref != null, userRef + " must exist");
+    return MessageId.create(ref.getObjectId().getName());
+  }
+
+  @Override
+  public MessageId fromMailMessage(MailMessage mailMessage) {
+    return MessageId.create(mailMessage.id() + "-REJECTION");
+  }
+
+  @Override
+  public MessageId fromReasonAccountIdAndTimestamp(
+      String reason, Account.Id accountId, Instant timestamp) {
+    return MessageId.create(reason + "-" + accountId.toString() + "-" + timestamp.toString());
+  }
+
+  private Ref getRef(String userRef, Project.NameKey project) {
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      return repository.getRefDatabase().findRef(userRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 68f6b7b..76b6993 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
@@ -751,6 +752,7 @@
     }
 
     addSoyParam("messageClass", messageClass);
+    addSoyParam("messageClassDisplay", EmailFactories.messageClassDisplay(messageClass));
     addSoyParam("footers", footers);
     addSoyEmailDataParam("settingsUrl", getSettingsUrl());
     addSoyEmailDataParam("instanceName", getInstanceName());
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index d71033a..94a0e37 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -267,8 +267,8 @@
     return p == null || p.asMatchable().match(changeData);
   }
 
-  private static class WatcherChangeQueryBuilder extends ChangeQueryBuilder {
-    private WatcherChangeQueryBuilder(Arguments args) {
+  public static class WatcherChangeQueryBuilder extends ChangeQueryBuilder {
+    public WatcherChangeQueryBuilder(Arguments args) {
       super(args);
     }
 
@@ -301,5 +301,16 @@
       // predicates.
       return Predicate.or(predicates);
     }
+
+    @Override
+    public Predicate<ChangeData> is(String value) throws QueryParseException {
+      if ("watched".equalsIgnoreCase(value)) {
+        // project watches cannot use "is:watched" as this would trigger an endless loop in
+        // IsWatchedByPredicate
+        throw new QueryParseException(
+            String.format("Operator 'is:watched' cannot be used in project watches."));
+      }
+      return super.is(value);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 5a439f8..59d1b9b 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -142,7 +142,7 @@
   @Override
   public boolean canEmail(String address) {
     if (!isEnabled()) {
-      logger.atWarning().log("Not emailing %s (email is disabled)", address);
+      logger.atFine().log("Not emailing %s (email is disabled)", address);
       return false;
     }
 
@@ -163,7 +163,7 @@
     if (denyrcpt.contains(address)
         || denyrcpt.contains(domain)
         || denyrcpt.contains("@" + domain)) {
-      logger.atInfo().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
+      logger.atFine().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
       return true;
     }
 
@@ -182,7 +182,7 @@
       return true;
     }
 
-    logger.atWarning().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
+    logger.atFine().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
     return false;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
index 1edc7bd..4bb347a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
 import com.google.gerrit.server.update.BatchUpdateListener;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -73,6 +74,8 @@
  * <p>This class is not thread safe.
  */
 public class ChangeDraftNotesUpdate extends AbstractChangeUpdate implements ChangeDraftUpdate {
+  private final ChangeNumberVirtualIdAlgorithm virtualIdFunc;
+
   public interface Factory extends ChangeDraftUpdateFactory {
     @Override
     ChangeDraftNotesUpdate create(
@@ -234,6 +237,7 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       ExperimentFeatures experimentFeatures,
+      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted ChangeNotes notes,
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
@@ -242,6 +246,7 @@
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
     this.experimentFeatures = experimentFeatures;
+    this.virtualIdFunc = virtualIdFunc;
   }
 
   @AssistedInject
@@ -250,6 +255,7 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       ExperimentFeatures experimentFeatures,
+      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted Change change,
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
@@ -258,6 +264,7 @@
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
     this.experimentFeatures = experimentFeatures;
+    this.virtualIdFunc = virtualIdFunc;
   }
 
   @Override
@@ -320,6 +327,7 @@
             draftsProject,
             noteUtil,
             experimentFeatures,
+            virtualIdFunc,
             new Change(getChange()),
             accountId,
             realAccountId,
@@ -430,7 +438,7 @@
 
   @Override
   protected String getRefName() {
-    return RefNames.refsDraftComments(getId(), accountId);
+    return RefNames.refsDraftComments(getVirtualId(), accountId);
   }
 
   @Override
@@ -447,4 +455,11 @@
   public boolean isEmpty() {
     return delete.isEmpty() && put.isEmpty();
   }
+
+  private Change.Id getVirtualId() {
+    Change change = getChange();
+    return virtualIdFunc == null
+        ? change.getId()
+        : virtualIdFunc.apply(change.getServerId(), change.getId());
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 7f0b068..233998d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -54,6 +54,9 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -219,6 +222,8 @@
      * requires using the Change index and should only be used when {@link
      * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
+    @UsedAt(UsedAt.Project.PLUGINS_ALL)
+    @Deprecated(since = "3.10", forRemoval = true)
     public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id changeId : changeIds) {
@@ -550,11 +555,21 @@
   }
 
   public ImmutableList<HumanComment> getDraftComments(Account.Id author) {
-    return getDraftComments(author, null);
+    return getDraftComments(author, null, null);
   }
 
-  ImmutableList<HumanComment> getDraftComments(Account.Id author, @Nullable Ref ref) {
-    loadDraftComments(author, ref);
+  public ImmutableList<HumanComment> getDraftComments(Account.Id author, @Nullable Ref ref) {
+    return getDraftComments(author, null, ref);
+  }
+
+  public ImmutableList<HumanComment> getDraftComments(
+      Account.Id author, @Nullable Change.Id virtualId) {
+    return getDraftComments(author, virtualId, null);
+  }
+
+  ImmutableList<HumanComment> getDraftComments(
+      Account.Id author, @Nullable Change.Id virtualId, @Nullable Ref ref) {
+    loadDraftComments(author, virtualId, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
     // the published map, and arise when the update to All-Users to delete them
     // during the publish operation failed.
@@ -573,17 +588,22 @@
    * However, this method will load the comments if no draft comments have been loaded or if the
    * caller would like the drafts for another author.
    */
-  private void loadDraftComments(Account.Id author, @Nullable Ref ref) {
+  private void loadDraftComments(
+      Account.Id author, @Nullable Change.Id virtualId, @Nullable Ref ref) {
     if (draftCommentNotes == null || !author.equals(draftCommentNotes.getAuthor()) || ref != null) {
-      draftCommentNotes = new DraftCommentNotes(args, getChangeId(), author, ref);
+      draftCommentNotes = new DraftCommentNotes(args, getChangeId(), virtualId, author, ref);
       draftCommentNotes.load();
     }
   }
 
   private void loadRobotComments() {
     if (robotCommentNotes == null) {
-      robotCommentNotes = new RobotCommentNotes(args, change);
-      robotCommentNotes.load();
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Load Robot Comments", Metadata.builder().changeId(change.getId().get()).build())) {
+        robotCommentNotes = new RobotCommentNotes(args, change);
+        robotCommentNotes.load();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index a17dfd2..08490a3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -62,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(10)
+            .version(11)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index e012942..eb6c15a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -364,6 +364,7 @@
     change.setOwner(c.owner());
     change.setDest(BranchNameKey.create(change.getProject(), c.branch()));
     change.setCreatedOn(c.createdOn());
+    change.setServerId(serverId());
     copyNonConstructorColumnsTo(change);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 186b49a..639633e 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -42,8 +42,15 @@
 public class DraftCommentNotes extends AbstractChangeNotes<DraftCommentNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final Change.Id virtualId;
+
   public interface Factory {
     DraftCommentNotes create(Change.Id changeId, Account.Id accountId);
+
+    DraftCommentNotes create(
+        @Assisted("changeId") Change.Id changeId,
+        @Assisted("virtualId") Change.Id virtualId,
+        Account.Id accountId);
   }
 
   private final Account.Id author;
@@ -54,11 +61,26 @@
 
   @AssistedInject
   DraftCommentNotes(Args args, @Assisted Change.Id changeId, @Assisted Account.Id author) {
-    this(args, changeId, author, null);
+    this(args, changeId, null, author, null);
   }
 
-  DraftCommentNotes(Args args, Change.Id changeId, Account.Id author, @Nullable Ref ref) {
+  @AssistedInject
+  DraftCommentNotes(
+      Args args,
+      @Assisted("changeId") Change.Id changeId,
+      @Assisted("virtualId") Change.Id virtualId,
+      @Assisted Account.Id author) {
+    this(args, changeId, virtualId, author, null);
+  }
+
+  DraftCommentNotes(
+      Args args,
+      Change.Id changeId,
+      @Nullable Change.Id virtualId,
+      Account.Id author,
+      @Nullable Ref ref) {
     super(args, changeId, null);
+    this.virtualId = virtualId;
     this.author = requireNonNull(author);
     this.ref = ref;
     if (ref != null) {
@@ -94,7 +116,7 @@
 
   @Override
   protected String getRefName() {
-    return refsDraftComments(getChangeId(), author);
+    return refsDraftComments(virtualId != null ? virtualId : getChangeId(), author);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
index 27c59f9..ea3dd0a 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -43,15 +44,18 @@
   private final DraftCommentNotes.Factory draftCommentNotesFactory;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
+  private final ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm;
 
   @Inject
   DraftCommentsNotesReader(
       DraftCommentNotes.Factory draftCommentNotesFactory,
       GitRepositoryManager repoManager,
-      AllUsersName allUsers) {
+      AllUsersName allUsers,
+      ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm) {
     this.draftCommentNotesFactory = draftCommentNotesFactory;
     this.repoManager = repoManager;
     this.allUsers = allUsers;
+    this.virtualIdAlgorithm = virtualIdAlgorithm;
   }
 
   @Override
@@ -64,7 +68,7 @@
 
   @Override
   public List<HumanComment> getDraftsByChangeAndDraftAuthor(ChangeNotes notes, Account.Id author) {
-    return sort(new ArrayList<>(notes.getDraftComments(author)));
+    return sort(new ArrayList<>(notes.getDraftComments(author, getVirtualId(notes))));
   }
 
   @Override
@@ -77,7 +81,7 @@
   public List<HumanComment> getDraftsByPatchSetAndDraftAuthor(
       ChangeNotes notes, PatchSet.Id psId, Account.Id author) {
     return sort(
-        notes.load().getDraftComments(author).stream()
+        notes.load().getDraftComments(author, getVirtualId(notes)).stream()
             .filter(c -> c.key.patchSetId == psId.get())
             .collect(Collectors.toList()));
   }
@@ -136,7 +140,7 @@
   private List<Ref> getDraftRefs(ChangeNotes notes) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return repo.getRefDatabase()
-          .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(notes.getChangeId()));
+          .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(getVirtualId(notes)));
     } catch (IOException e) {
       throw new StorageException(e);
     }
@@ -145,4 +149,10 @@
   private List<HumanComment> sort(List<HumanComment> comments) {
     return CommentsUtil.sort(comments);
   }
+
+  private Change.Id getVirtualId(ChangeNotes notes) {
+    return virtualIdAlgorithm == null
+        ? notes.getChangeId()
+        : virtualIdAlgorithm.apply(notes.getServerId(), notes.getChangeId());
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 033a53f..8369a35 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -178,9 +178,7 @@
   static RetryerBuilder<ImmutableList<Integer>> retryerBuilder() {
     return RetryerBuilder.<ImmutableList<Integer>>newBuilder()
         .retryIfException(
-            t ->
-                t instanceof StorageException
-                    && ((StorageException) t).getCause() instanceof LockFailureException)
+            t -> t instanceof StorageException && t.getCause() instanceof LockFailureException)
         .withWaitStrategy(
             WaitStrategies.join(
                 WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
diff --git a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
index c52f082..f13b832 100644
--- a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
+++ b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
@@ -85,31 +85,31 @@
   }
 
   @Override
-  public boolean isStarred(Account.Id accountId, Change.Id changeId) {
+  public boolean isStarred(Account.Id accountId, Change.Id virtualId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getStarRef(repo, RefNames.refsStarredChanges(changeId, accountId)).isPresent();
+      return getStarRef(repo, RefNames.refsStarredChanges(virtualId, accountId)).isPresent();
     } catch (IOException e) {
       throw new StorageException(
           String.format(
               "Reading stars from change %d for account %d failed",
-              changeId.get(), accountId.get()),
+              virtualId.get(), accountId.get()),
           e);
     }
   }
 
   @Override
-  public void star(Account.Id accountId, Change.Id changeId) {
-    updateStar(accountId, changeId, true);
+  public void star(Account.Id accountId, Change.Id virtualId) {
+    updateStar(accountId, virtualId, true);
   }
 
   @Override
-  public void unstar(Account.Id accountId, Change.Id changeId) {
-    updateStar(accountId, changeId, false);
+  public void unstar(Account.Id accountId, Change.Id virtualId) {
+    updateStar(accountId, virtualId, false);
   }
 
-  private void updateStar(Account.Id accountId, Change.Id changeId, boolean shouldAdd) {
+  private void updateStar(Account.Id accountId, Change.Id virtualId, boolean shouldAdd) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      String refName = RefNames.refsStarredChanges(changeId, accountId);
+      String refName = RefNames.refsStarredChanges(virtualId, accountId);
       if (shouldAdd) {
         addRef(repo, refName, null);
       } else {
@@ -120,16 +120,16 @@
       }
     } catch (IOException e) {
       throw new StorageException(
-          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
+          String.format("Star change %d for account %d failed", virtualId.get(), accountId.get()),
           e);
     }
   }
 
   @Override
   public Set<Change.Id> areStarred(
-      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
+      Repository allUsersRepo, List<Change.Id> virtualIds, Account.Id caller) {
     List<String> starRefs =
-        changeIds.stream()
+        virtualIds.stream()
             .map(c -> RefNames.refsStarredChanges(c, caller))
             .collect(Collectors.toList());
     try {
@@ -140,21 +140,21 @@
     } catch (IOException e) {
       logger.atWarning().withCause(e).log(
           "Failed getting starred changes for account %d within changes: %s",
-          caller.get(), Joiner.on(", ").join(changeIds));
+          caller.get(), Joiner.on(", ").join(virtualIds));
       return ImmutableSet.of();
     }
   }
 
   @Override
-  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
+  public void unstarAllForChangeDeletion(Change.Id virtualId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
       batchUpdate.setAllowNonFastForwards(true);
       batchUpdate.setRefLogIdent(serverIdent.get());
-      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        String refName = RefNames.refsStarredChanges(changeId, accountId);
+      batchUpdate.setRefLogMessage("Unstar change " + virtualId.get(), true);
+      for (Account.Id accountId : getStars(repo, virtualId)) {
+        String refName = RefNames.refsStarredChanges(virtualId, accountId);
         Ref ref = repo.getRefDatabase().exactRef(refName);
         if (ref != null) {
           batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
@@ -166,7 +166,7 @@
           String message =
               String.format(
                   "Unstar change %d failed, ref %s could not be deleted: %s",
-                  changeId.get(), command.getRefName(), command.getResult());
+                  virtualId.get(), command.getRefName(), command.getResult());
           if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
             throw new LockFailureException(message, batchUpdate);
           }
@@ -177,11 +177,11 @@
   }
 
   @Override
-  public ImmutableList<Account.Id> byChange(Change.Id changeId) {
+  public ImmutableList<Account.Id> byChange(Change.Id virtualId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableList.Builder<Account.Id> builder = ImmutableList.builder();
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        Optional<Ref> starRef = getStarRef(repo, RefNames.refsStarredChanges(changeId, accountId));
+      for (Account.Id accountId : getStars(repo, virtualId)) {
+        Optional<Ref> starRef = getStarRef(repo, RefNames.refsStarredChanges(virtualId, accountId));
         if (starRef.isPresent()) {
           builder.add(accountId);
         }
@@ -189,7 +189,7 @@
       return builder.build();
     } catch (IOException e) {
       throw new StorageException(
-          String.format("Get accounts that starred change %d failed", changeId.get()), e);
+          String.format("Get accounts that starred change %d failed", virtualId.get()), e);
     }
   }
 
@@ -223,9 +223,9 @@
     }
   }
 
-  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id virtualId)
       throws IOException {
-    String prefix = RefNames.refsStarredChangesPrefix(changeId);
+    String prefix = RefNames.refsStarredChangesPrefix(virtualId);
     RefDatabase refDb = allUsers.getRefDatabase();
     return refDb.getRefsByPrefix(prefix).stream()
         .map(r -> r.getName().substring(prefix.length()))
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/patch/ApplyPatchUtil.java
similarity index 90%
rename from java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
rename to java/com/google/gerrit/server/patch/ApplyPatchUtil.java
index 56b3842..11471c5 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/patch/ApplyPatchUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.patch.DiffUtil;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -41,6 +40,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.patch.Patch;
 import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -80,6 +80,9 @@
     }
     try {
       PatchApplier applier = new PatchApplier(repo, tip, oi);
+      if (Boolean.TRUE.equals(input.allowConflicts)) {
+        applier.allowConflicts();
+      }
       PatchApplier.Result applyResult = applier.applyPatch(patch);
       return applyResult;
     } catch (IOException e) {
@@ -105,7 +108,7 @@
    *
    * @param message the first message piece, excluding footers
    * @param footerLines footer lines to append to the message
-   * @param originalPatch to compare the result patch to
+   * @param patchInput API input that triggered this action
    * @param resultPatch to validate accuracy for
    * @return the commit message
    * @throws BadRequestException if the commit message cannot be sanitized
@@ -113,7 +116,7 @@
   public static String buildCommitMessage(
       String message,
       List<FooterLine> footerLines,
-      String originalPatch,
+      ApplyPatchInput patchInput,
       String resultPatch,
       List<PatchApplier.Result.Error> errors)
       throws BadRequestException {
@@ -121,13 +124,19 @@
 
     boolean appendOriginalPatch = false;
     boolean appendResultPatch = false;
-    String decodedOriginalPatch = decodeIfNecessary(originalPatch);
+    String decodedOriginalPatch = decodeIfNecessary(patchInput.patch);
     if (!errors.isEmpty()) {
-      res.append(
-          "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
-              + "\nPLEASE REVIEW CAREFULLY.\nErrors:\n"
-              + errors.stream().map(Objects::toString).collect(Collectors.joining("\n")));
-      appendOriginalPatch = true;
+      if (errors.stream().allMatch(Error::isGitConflict)) {
+        res.append(
+            "\n\nATTENTION: Conflicts occurred while applying the patch.\n"
+                + "Please resolve conflict markers.");
+      } else {
+        res.append(
+            "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
+                + "\nPLEASE REVIEW CAREFULLY.\nErrors:\n"
+                + errors.stream().map(Objects::toString).collect(Collectors.joining("\n")));
+        appendOriginalPatch = true;
+      }
     } else {
       // Only surface the diff if no explicit errors occurred.
       Optional<String> patchDiff = verifyAppliedPatch(decodedOriginalPatch, resultPatch);
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 1dacde7..74f5886 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
@@ -99,7 +98,6 @@
   private final boolean save;
   private final boolean useDiff3;
   private final ThreeWayMergeStrategy configuredMergeStrategy;
-  private final CallerFinder callerFinder;
 
   @Inject
   AutoMerger(
@@ -126,7 +124,6 @@
     this.useDiff3 = diff3ConflictView(cfg);
     this.gerritIdentProvider = gerritIdentProvider;
     this.configuredMergeStrategy = MergeUtil.getMergeStrategy(cfg);
-    this.callerFinder = CallerFinder.builder().addTarget(AutoMerger.class).build();
   }
 
   /**
@@ -252,8 +249,7 @@
     if (couldMerge) {
       treeId = m.getResultTreeId();
       logger.atFine().log(
-          "AutoMerge treeId=%s (no conflicts, inserter: %s, caller: %s)",
-          treeId.name(), m.getObjectInserter(), callerFinder.findCallerLazy());
+          "AutoMerge treeId=%s (no conflicts, inserter: %s)", treeId.name(), m.getObjectInserter());
     } else {
       if (m.getResultTreeId() != null) {
         // Merging with conflicts below uses the same DirCache instance that has been used by the
@@ -289,8 +285,7 @@
               m.getMergeResults(),
               useDiff3);
       logger.atFine().log(
-          "AutoMerge treeId=%s (with conflicts, inserter: %s, caller: %s)",
-          treeId.name(), nonFlushingInserter, callerFinder.findCallerLazy());
+          "AutoMerge treeId=%s (with conflicts, inserter: %s)", treeId.name(), nonFlushingInserter);
     }
 
     rw.parseHeaders(merge);
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 27c6ca6..44810e8 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
@@ -84,7 +83,6 @@
   private final ModifiedFilesLoader.Factory modifiedFilesLoaderFactory;
   private final FileDiffCache fileDiffCache;
   private final BaseCommitUtil baseCommitUtil;
-  private final CallerFinder callerFinder;
 
   public static Module module() {
     return new CacheModule() {
@@ -113,11 +111,6 @@
     this.modifiedFilesLoaderFactory = modifiedFilesLoaderFactory;
     this.fileDiffCache = fileDiffCache;
     this.baseCommitUtil = baseCommit;
-    this.callerFinder =
-        CallerFinder.builder()
-            .addTarget(DiffOperations.class)
-            .addTarget(DiffOperationsImpl.class)
-            .build();
   }
 
   @Override
@@ -129,8 +122,8 @@
         ObjectReader reader = ins.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
       logger.atFine().log(
-          "Opened repo %s to list modified files against parent for %s (inserter: %s, caller: %s)",
-          project, newCommit.name(), ins, callerFinder.findCallerLazy());
+          "Opened repo %s to list modified files against parent for %s (inserter: %s)",
+          project, newCommit.name(), ins);
       DiffParameters diffParams =
           computeDiffParameters(project, newCommit, parent, new RepoView(repo, revWalk, ins), ins);
       return getModifiedFiles(diffParams, diffOptions);
@@ -210,8 +203,8 @@
         ObjectReader reader = ins.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
       logger.atFine().log(
-          "Opened repo %s to get modified file against parent for %s (inserter: %s, caller: %s)",
-          project, newCommit.name(), ins, callerFinder.findCallerLazy());
+          "Opened repo %s to get modified file against parent for %s (inserter: %s)",
+          project, newCommit.name(), ins);
       DiffParameters diffParams =
           computeDiffParameters(project, newCommit, parent, new RepoView(repo, revWalk, ins), ins);
       FileDiffCacheKey key =
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index 7a8180bd..c3a6807 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -61,7 +61,7 @@
             .filter(f -> f.getKey().equals(fileName))
             .map(Map.Entry::getValue)
             .findFirst()
-            .orElse(FileDiffOutput.empty(fileName, ObjectId.zeroId(), ObjectId.zeroId()));
+            .orElseGet(() -> FileDiffOutput.empty(fileName, ObjectId.zeroId(), ObjectId.zeroId()));
 
     if (Patch.PATCHSET_LEVEL.equals(fileName)) {
       aTree = null;
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index f6faa4e..e280107 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.LabelId;
@@ -105,33 +107,26 @@
             CommentCumulativeSizeValidator.DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
-  public String apply(ChangeNotes notes, CurrentUser currentUser)
+  public String computeDiffFromModifiedFiles(
+      ChangeNotes notes, CurrentUser currentUser, ImmutableList<FileDiffOutput> modifiedFilesList)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
+
     PatchSet currentPatchset = notes.getCurrentPatchSet();
 
     Optional<PatchSet.Id> latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
+
     if (latestApprovedPatchsetId.isEmpty()
         || latestApprovedPatchsetId.get().get() == currentPatchset.id().get()) {
       // If the latest approved patchset is the current patchset, no need to return anything.
       return "";
     }
+
     StringBuilder diff =
         new StringBuilder(
             String.format(
                 "\n\n%d is the latest approved patch-set.\n",
                 latestApprovedPatchsetId.get().get()));
-    Map<String, FileDiffOutput> modifiedFiles =
-        listModifiedFiles(
-            notes.getProjectName(),
-            currentPatchset,
-            notes.getPatchSets().get(latestApprovedPatchsetId.get()));
-
-    // To make the message a bit more concise, we skip the magic files.
-    List<FileDiffOutput> modifiedFilesList =
-        modifiedFiles.values().stream()
-            .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
-            .collect(Collectors.toList());
 
     if (modifiedFilesList.isEmpty()) {
       diff.append(
@@ -140,6 +135,7 @@
     }
 
     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, DEFAULT_POST_SUBMIT_SIZE_LIMIT),
@@ -182,9 +178,35 @@
                 isDiffTooLarge));
       }
     }
+
     return diff.toString();
   }
 
+  /** Returns the list of modified files */
+  public ImmutableList<FileDiffOutput> apply(ChangeNotes notes, CurrentUser currentUser)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
+    PatchSet currentPatchset = notes.getCurrentPatchSet();
+
+    Optional<PatchSet.Id> latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
+    if (latestApprovedPatchsetId.isEmpty()
+        || latestApprovedPatchsetId.get().get() == currentPatchset.id().get()) {
+      // If the latest approved patchset is the current patchset, no need to return anything.
+      return ImmutableList.of();
+    }
+
+    Map<String, FileDiffOutput> modifiedFiles =
+        listModifiedFiles(
+            notes.getProjectName(),
+            currentPatchset,
+            notes.getPatchSets().get(latestApprovedPatchsetId.get()));
+
+    // To make the message a bit more concise, we skip the magic files.
+    return modifiedFiles.values().stream()
+        .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
+        .collect(toImmutableList());
+  }
+
   private String getDiffForFile(
       ChangeNotes notes,
       PatchSet.Id currentPatchsetId,
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 84eda51..51de21b 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -464,7 +464,7 @@
     private static DiffOptions create(
         ObjectId oldTree,
         ObjectId newTree,
-        Integer renameScore,
+        int renameScore,
         Whitespace whitespace,
         DiffAlgorithm diffAlgorithm) {
       return new AutoValue_GitFileDiffCacheImpl_DiffOptions(
@@ -475,7 +475,7 @@
 
     abstract ObjectId newTree();
 
-    abstract Integer renameScore();
+    abstract int renameScore();
 
     abstract Whitespace whitespace();
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
index 2e18e93..d127817 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -87,7 +87,7 @@
 
     public abstract Builder newFilePath(String value);
 
-    public abstract Builder renameScore(Integer value);
+    public abstract Builder renameScore(int value);
 
     public Builder disableRenameDetection() {
       renameScore(-1);
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
index 622f0cf..ba7caed 100644
--- a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -53,7 +53,8 @@
 
   protected abstract String permissionPrefix();
 
-  protected String permissionName() {
+  @Override
+  public String permissionName() {
     if (forUser == ON_BEHALF_OF) {
       return permissionPrefix() + "As";
     }
@@ -119,8 +120,6 @@
       return label.value();
     }
 
-    public abstract String permissionName();
-
     @Override
     public final String describeForException() {
       if (forUser == ON_BEHALF_OF) {
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 664ffa2..5d79d09 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -31,19 +31,26 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.Map;
 import java.util.Set;
+import javax.inject.Inject;
 
 /** Access control management for a user accessing a single change. */
-class ChangeControl {
+public class ChangeControl {
+  public interface Factory {
+    ChangeControl create(RefControl refControl, ChangeData changeData);
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final RefControl refControl;
   private final ChangeData changeData;
 
-  ChangeControl(RefControl refControl, ChangeData changeData) {
+  @Inject
+  protected ChangeControl(@Assisted RefControl refControl, @Assisted ChangeData changeData) {
     this.refControl = refControl;
     this.changeData = changeData;
   }
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index d9f83c7..b9868dd 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -101,6 +101,11 @@
   }
 
   @Override
+  public String permissionName() {
+    return GerritPermission.describeEnumValue(this);
+  }
+
+  @Override
   public Optional<String> hintForException() {
     return Optional.ofNullable(hint);
   }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index 3f84dff..b5ec4c9 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -14,23 +14,32 @@
 
 package com.google.gerrit.server.permissions;
 
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.inject.AbstractModule;
+import com.google.inject.PrivateModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 /** Binds the default {@link PermissionBackend}. */
-public class DefaultPermissionBackendModule extends AbstractModule {
+public class DefaultPermissionBackendModule extends PrivateModule {
+
   @Override
   protected void configure() {
-    install(new LegacyControlsModule());
+    // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
+    install(new FactoryModuleBuilder().build(DefaultRefFilter.Factory.class));
+    installRefControlFactory();
+    installChangeControlFactory();
+    installProjectControlFactory();
+    // Expose only ProjectControl.Factory, so other modules can't use RefControl and ChangeControl.
+    expose(ProjectControl.Factory.class);
   }
 
-  /** Binds legacy ProjectControl, RefControl, ChangeControl. */
-  public static class LegacyControlsModule extends FactoryModule {
-    @Override
-    protected void configure() {
-      // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
-      factory(ProjectControl.Factory.class);
-      factory(DefaultRefFilter.Factory.class);
-    }
+  protected void installProjectControlFactory() {
+    install(new FactoryModuleBuilder().build(ProjectControl.Factory.class));
+  }
+
+  protected void installChangeControlFactory() {
+    install(new FactoryModuleBuilder().build(ChangeControl.Factory.class));
+  }
+
+  protected void installRefControlFactory() {
+    install(new FactoryModuleBuilder().build(RefControl.Factory.class));
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index f179045..5913673 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -16,7 +16,6 @@
 
 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;
 
@@ -57,10 +56,10 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-class DefaultRefFilter {
+public class DefaultRefFilter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  interface Factory {
+  public interface Factory {
     DefaultRefFilter create(ProjectControl projectControl);
   }
 
@@ -130,7 +129,6 @@
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
     logger.atFinest().log("Calling user: %s", user.getLoggableName());
-    logger.atFinest().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
     logger.atFinest().log(
         "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
         skipFullRefEvaluationIfAllRefsAreVisible);
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 3429978..d83353c 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -160,4 +160,9 @@
   public String describeForException() {
     return GerritPermission.describeEnumValue(this);
   }
+
+  @Override
+  public String permissionName() {
+    return GerritPermission.describeEnumValue(this);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index ac9ac98..dbdd26f 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -21,6 +21,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelType;
@@ -261,6 +263,19 @@
       }
       return allowed;
     }
+
+    /**
+     * Additional filter for changes query for reducing the cardinality of the results for current
+     * user.
+     *
+     * @return additional query filter to add to all user's change queries, null if no filters are
+     *     required.
+     * @since 3.11
+     */
+    @UsedAt(UsedAt.Project.MODULE_VIRTUALHOST)
+    public @Nullable String filterQueryChanges() {
+      return null;
+    }
   }
 
   /** PermissionBackend scoped to a user and project. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionDeniedException.java b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
index b9e86cd..a007cf7 100644
--- a/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
+++ b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
@@ -55,4 +55,8 @@
   public Optional<String> getResource() {
     return resource;
   }
+
+  public GerritPermission getPermission() {
+    return permission;
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index fab894e..9a6db5d 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -22,6 +22,7 @@
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -38,10 +39,13 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
@@ -59,53 +63,54 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /** Access control management for a user accessing a project's data. */
-class ProjectControl {
-  interface Factory {
+public class ProjectControl {
+  public interface Factory {
     ProjectControl create(CurrentUser who, ProjectState ps);
   }
 
   private final Set<AccountGroup.UUID> uploadGroups;
   private final Set<AccountGroup.UUID> receiveGroups;
   private final PermissionBackend permissionBackend;
-  private final RefVisibilityControl refVisibilityControl;
-  private final GitRepositoryManager repositoryManager;
   private final CurrentUser user;
   private final ProjectState state;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
-  private final ChangeData.Factory changeDataFactory;
   private final AllUsersName allUsersName;
+  private final RefControl.Factory refControlFactory;
+  private final ChangeControl.Factory changeControlFactory;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
   private Boolean declaredOwner;
+  private Config cfg;
 
   @Inject
-  ProjectControl(
+  protected ProjectControl(
       @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       PermissionCollection.Factory permissionFilter,
       PermissionBackend permissionBackend,
-      RefVisibilityControl refVisibilityControl,
-      GitRepositoryManager repositoryManager,
       DefaultRefFilter.Factory refFilterFactory,
-      ChangeData.Factory changeDataFactory,
       AllUsersName allUsersName,
+      @GerritServerConfig Config cfg,
+      RefControl.Factory refControlFactory,
+      ChangeControl.Factory changeControlFactory,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.uploadGroups = uploadGroups;
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
-    this.refVisibilityControl = refVisibilityControl;
-    this.repositoryManager = repositoryManager;
     this.refFilterFactory = refFilterFactory;
-    this.changeDataFactory = changeDataFactory;
     this.allUsersName = allUsersName;
+    this.cfg = cfg;
+    this.refControlFactory = refControlFactory;
+    this.changeControlFactory = changeControlFactory;
     user = who;
     state = ps;
   }
@@ -115,7 +120,7 @@
   }
 
   ChangeControl controlFor(ChangeData cd) {
-    return new ChangeControl(controlForRef(cd.branchOrThrow()), cd);
+    return changeControlFactory.create(controlForRef(cd.branchOrThrow()), cd);
   }
 
   RefControl controlForRef(BranchNameKey ref) {
@@ -129,19 +134,17 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl =
-          new RefControl(
-              changeDataFactory, refVisibilityControl, this, repositoryManager, refName, relevant);
+      ctl = refControlFactory.create(this, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
   }
 
-  CurrentUser getUser() {
+  protected CurrentUser getUser() {
     return user;
   }
 
-  ProjectState getProjectState() {
+  protected ProjectState getProjectState() {
     return state;
   }
 
@@ -290,24 +293,29 @@
   }
 
   private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
-    boolean canPerform = false;
-    Set<String> patterns = allRefPatterns(permission);
-    if (patterns.contains(ALL)) {
-      // Only possible if granted on the pattern that
-      // matches every possible reference.  Check all
-      // patterns also have the permission.
-      //
-      for (String pattern : patterns) {
-        if (controlForRef(pattern).canPerform(permission)) {
-          canPerform = true;
-        } else if (ignore.contains(pattern)) {
-          continue;
-        } else {
-          return false;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "ProjectControl#canPerformOnAllRefs",
+            Metadata.builder().projectName(getProject().getName()).build())) {
+      boolean canPerform = false;
+      Set<String> patterns = allRefPatterns(permission);
+      if (patterns.contains(ALL)) {
+        // Only possible if granted on the pattern that
+        // matches every possible reference.  Check all
+        // patterns also have the permission.
+        //
+        for (String pattern : patterns) {
+          if (controlForRef(pattern).canPerform(permission)) {
+            canPerform = true;
+          } else if (ignore.contains(pattern)) {
+            continue;
+          } else {
+            return false;
+          }
         }
       }
+      return canPerform;
     }
-    return canPerform;
   }
 
   private Set<String> allRefPatterns(String permissionName) {
@@ -347,6 +355,13 @@
     }
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected boolean canUpdateConfigWithoutCreatingChange() {
+    // In google, the implementation use more complicated logic - this is why it is placed inside
+    // a ProjectControl.
+    return !cfg.getBoolean("gerrit", "requireChangeForConfigUpdate", false);
+  }
+
   private class ForProjectImpl extends ForProject {
     private String resourcePath;
 
@@ -468,6 +483,9 @@
         case READ_REFLOG:
         case WRITE_CONFIG:
           return isOwner();
+
+        case UPDATE_CONFIG_WITHOUT_CREATING_CHANGE:
+          return canUpdateConfigWithoutCreatingChange();
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index fc31e96..c3e9740 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -100,7 +100,10 @@
   READ_REFLOG,
 
   /** Can push to at least one reference within the repository. */
-  PUSH_AT_LEAST_ONE_REF("push to at least one ref");
+  PUSH_AT_LEAST_ONE_REF("push to at least one ref"),
+
+  /** Can use restapi to update project config without review. */
+  UPDATE_CONFIG_WITHOUT_CREATING_CHANGE("update config without creating a change using rest api");
 
   private final String description;
 
@@ -116,4 +119,9 @@
   public String describeForException() {
     return description != null ? description : GerritPermission.describeEnumValue(this);
   }
+
+  @Override
+  public String permissionName() {
+    return GerritPermission.describeEnumValue(this);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index aba9522..ce8548d 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -31,13 +31,14 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.MagicBranch;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
@@ -48,8 +49,17 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-/** Manages access control for Git references (aka branches, tags). */
-class RefControl {
+/**
+ * Manages access control for Git references (aka branches, tags).
+ *
+ * <p>Do not use this class directly - instead use {@link ProjectControl} class. This class is
+ * public only because it is extended in google-owned implementation.
+ */
+public class RefControl {
+  public interface Factory {
+    RefControl create(ProjectControl projectControl, String ref, PermissionCollection relevant);
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeData.Factory changeDataFactory;
@@ -61,8 +71,6 @@
   /** All permissions that apply to this reference. */
   private final PermissionCollection relevant;
 
-  private final CallerFinder callerFinder;
-
   // The next 4 members are cached canPerform() permissions.
 
   private Boolean owner;
@@ -70,29 +78,23 @@
   private Boolean canForgeCommitter;
   private Boolean hasReadPermissionOnRef;
 
-  RefControl(
+  @Inject
+  protected RefControl(
       ChangeData.Factory changeDataFactory,
       RefVisibilityControl refVisibilityControl,
-      ProjectControl projectControl,
       GitRepositoryManager repositoryManager,
-      String ref,
-      PermissionCollection relevant) {
+      @Assisted ProjectControl projectControl,
+      @Assisted String ref,
+      @Assisted PermissionCollection relevant) {
     this.changeDataFactory = changeDataFactory;
     this.refVisibilityControl = refVisibilityControl;
-    this.projectControl = projectControl;
     this.repositoryManager = repositoryManager;
+    this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
-    this.callerFinder =
-        CallerFinder.builder()
-            .addTarget(PermissionBackend.class)
-            .matchSubClasses(true)
-            .matchInnerClasses(true)
-            .skip(1)
-            .build();
   }
 
-  ProjectControl getProjectControl() {
+  protected ProjectControl getProjectControl() {
     return projectControl;
   }
 
@@ -100,6 +102,10 @@
     return projectControl.getUser();
   }
 
+  protected String getRefName() {
+    return refName;
+  }
+
   /** Is this user a ref owner? */
   boolean isOwner() {
     if (owner == null) {
@@ -149,7 +155,7 @@
   }
 
   /** Returns true if this user can submit patch sets to this ref */
-  boolean canSubmit(boolean isChangeOwner) {
+  protected boolean canSubmit(boolean isChangeOwner) {
     if (RefNames.REFS_CONFIG.equals(refName)) {
       // Always allow project owners to submit configuration changes.
       // Submitting configuration changes modifies the access control
@@ -307,7 +313,7 @@
     return pr.getAction() == Action.BLOCK && (!pr.getForce() || withForce);
   }
 
-  private PermissionRange toRange(String permissionName, boolean isChangeOwner) {
+  protected PermissionRange toRange(String permissionName, boolean isChangeOwner) {
     int blockAllowMin = Integer.MIN_VALUE, blockAllowMax = Integer.MAX_VALUE;
 
     projectLoop:
@@ -435,7 +441,6 @@
                 projectControl.getProject().getName(),
                 refName);
         LoggingContext.getInstance().addAclLogRecord(logMessage);
-        logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
       }
       return false;
     }
@@ -455,7 +460,7 @@
                   pr.getGroup().getUUID().get(),
                   pr);
           LoggingContext.getInstance().addAclLogRecord(logMessage);
-          logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+          logger.atFine().log("%s", logMessage);
         }
         return true;
       }
@@ -471,7 +476,7 @@
               projectControl.getProject().getName(),
               refName);
       LoggingContext.getInstance().addAclLogRecord(logMessage);
-      logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+      logger.atFine().log("%s", logMessage);
     }
     return false;
   }
@@ -620,98 +625,98 @@
     public BooleanCondition testCond(RefPermission perm) {
       return new PermissionBackendCondition.ForRef(this, perm, getUser());
     }
+  }
 
-    private boolean can(RefPermission perm) throws PermissionBackendException {
-      switch (perm) {
-        case READ:
-          /* Internal users such as plugin users should be able to read all refs. */
-          if (getUser().isInternalUser()) {
-            return true;
-          }
-          if (refName.startsWith(Constants.R_TAGS)) {
-            return isTagVisible();
-          }
-          return refVisibilityControl.isVisible(projectControl, refName);
-        case CREATE:
-          // TODO This isn't an accurate test.
-          return canPerform(refPermissionName(perm));
-        case DELETE:
-          return canDelete();
-        case UPDATE:
-          return canUpdate();
-        case FORCE_UPDATE:
-          return canForceUpdate();
-        case SET_HEAD:
-          return projectControl.isOwner();
+  protected boolean can(RefPermission perm) throws PermissionBackendException {
+    switch (perm) {
+      case READ:
+        /* Internal users such as plugin users should be able to read all refs. */
+        if (getUser().isInternalUser()) {
+          return true;
+        }
+        if (refName.startsWith(Constants.R_TAGS)) {
+          return isTagVisible();
+        }
+        return refVisibilityControl.isVisible(projectControl, refName);
+      case CREATE:
+        // TODO This isn't an accurate test.
+        return canPerform(refPermissionName(perm));
+      case DELETE:
+        return canDelete();
+      case UPDATE:
+        return canUpdate();
+      case FORCE_UPDATE:
+        return canForceUpdate();
+      case SET_HEAD:
+        return projectControl.isOwner();
 
-        case FORGE_AUTHOR:
-          return canForgeAuthor();
-        case FORGE_COMMITTER:
-          return canForgeCommitter();
-        case FORGE_SERVER:
-          return canForgeGerritServerIdentity();
-        case MERGE:
-          return canUploadMerges();
+      case FORGE_AUTHOR:
+        return canForgeAuthor();
+      case FORGE_COMMITTER:
+        return canForgeCommitter();
+      case FORGE_SERVER:
+        return canForgeGerritServerIdentity();
+      case MERGE:
+        return canUploadMerges();
 
-        case CREATE_CHANGE:
-          return canUpload();
+      case CREATE_CHANGE:
+        return canUpload();
 
-        case CREATE_TAG:
-        case CREATE_SIGNED_TAG:
-          return canPerform(refPermissionName(perm));
+      case CREATE_TAG:
+      case CREATE_SIGNED_TAG:
+        return canPerform(refPermissionName(perm));
 
-        case UPDATE_BY_SUBMIT:
-          return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).canSubmit(true);
+      case UPDATE_BY_SUBMIT:
+        return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).canSubmit(true);
 
-        case READ_PRIVATE_CHANGES:
-          return canPerform(Permission.VIEW_PRIVATE_CHANGES);
+      case READ_PRIVATE_CHANGES:
+        return canPerform(Permission.VIEW_PRIVATE_CHANGES);
 
-        case READ_CONFIG:
-          return projectControl
-              .controlForRef(RefNames.REFS_CONFIG)
-              .canPerform(RefPermission.READ.name());
-        case WRITE_CONFIG:
-          return isOwner();
+      case READ_CONFIG:
+        return projectControl
+            .controlForRef(RefNames.REFS_CONFIG)
+            .canPerform(RefPermission.READ.name());
+      case WRITE_CONFIG:
+        return isOwner();
 
-        case SKIP_VALIDATION:
-          return canForgeAuthor()
-              && canForgeCommitter()
-              && canForgeGerritServerIdentity()
-              && canUploadMerges();
-      }
-      throw new PermissionBackendException(perm + " unsupported");
+      case SKIP_VALIDATION:
+        return canForgeAuthor()
+            && canForgeCommitter()
+            && canForgeGerritServerIdentity()
+            && canUploadMerges();
+    }
+    throw new PermissionBackendException(perm + " unsupported");
+  }
+
+  private boolean isTagVisible() throws PermissionBackendException {
+    if (projectControl.asForProject().test(ProjectPermission.READ)) {
+      // The user has READ on refs/* with no effective block permission. This is the broadest
+      // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
+      // so we have to assume that these users can see all tags because there could be tags that
+      // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
+      // matches Gerrit's historic behavior.
+      // This makes it so that these users could see commits that they can't see otherwise
+      // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
+      // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
+      // is a negligible risk.
+      return true;
     }
 
-    private boolean isTagVisible() throws PermissionBackendException {
-      if (projectControl.asForProject().test(ProjectPermission.READ)) {
-        // The user has READ on refs/* with no effective block permission. This is the broadest
-        // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
-        // so we have to assume that these users can see all tags because there could be tags that
-        // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
-        // matches Gerrit's historic behavior.
-        // This makes it so that these users could see commits that they can't see otherwise
-        // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
-        // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
-        // is a negligible risk.
-        return true;
+    try (Repository repo =
+        repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
+      // Tag visibility requires going through RefFilter because it entails loading all taggable
+      // refs and filtering them all by visibility.
+      Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
+      if (resolvedRef == null) {
+        return false;
       }
-
-      try (Repository repo =
-          repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
-        // Tag visibility requires going through RefFilter because it entails loading all taggable
-        // refs and filtering them all by visibility.
-        Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
-        if (resolvedRef == null) {
-          return false;
-        }
-        return projectControl.asForProject()
-            .filter(
-                ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
-            .stream()
-            .anyMatch(r -> refName.equals(r.getName()));
-      } catch (IOException e) {
-        throw new PermissionBackendException(e);
-      }
+      return projectControl.asForProject()
+          .filter(
+              ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .anyMatch(r -> refName.equals(r.getName()));
+    } catch (IOException e) {
+      throw new PermissionBackendException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefPermission.java b/java/com/google/gerrit/server/permissions/RefPermission.java
index 09eed24..34c46af 100644
--- a/java/com/google/gerrit/server/permissions/RefPermission.java
+++ b/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -88,4 +88,9 @@
   public String describeForException() {
     return description != null ? description : GerritPermission.describeEnumValue(this);
   }
+
+  @Override
+  public String permissionName() {
+    return GerritPermission.describeEnumValue(this);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index c2d1139..bae1fef 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -38,7 +38,7 @@
  * authoritatively tell if a ref is accessible by a user.
  */
 @Singleton
-class RefVisibilityControl {
+public class RefVisibilityControl {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/plugins/DisablePlugin.java b/java/com/google/gerrit/server/plugins/DisablePlugin.java
index 9e238f8..d845ec17 100644
--- a/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -53,7 +53,12 @@
     if (mandatoryPluginsCollection.contains(name)) {
       throw new MethodNotAllowedException("Plugin " + name + " is mandatory");
     }
-    loader.disablePlugins(ImmutableSet.of(name));
+    try {
+      loader.disablePlugins(ImmutableSet.of(name));
+    } catch (PluginInstallException e) {
+      throw new MethodNotAllowedException("Plugin " + name + " cannot be disabled", e);
+    }
+
     return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/Plugin.java b/java/com/google/gerrit/server/plugins/Plugin.java
index b5ff041..3de7e27 100644
--- a/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/java/com/google/gerrit/server/plugins/Plugin.java
@@ -78,7 +78,7 @@
 
   protected LifecycleManager manager;
 
-  private List<ReloadableRegistrationHandle<?>> reloadableHandles;
+  protected List<ReloadableRegistrationHandle<?>> reloadableHandles;
 
   public Plugin(
       String name, Path srcPath, PluginUser pluginUser, FileSnapshot snapshot, ApiType apiType) {
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 7ecb6c6..8cfc6f3 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -52,6 +52,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
+import com.google.inject.util.Modules;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.ParameterizedType;
 import java.util.Collections;
@@ -175,16 +176,7 @@
     final Module db = copy(dbInjector);
     final Module cm = copy(cfgInjector);
     final Module sm = copy(sysInjector);
-    sysModule =
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            install(copyConfigModule);
-            install(db);
-            install(cm);
-            install(sm);
-          }
-        };
+    sysModule = Modules.combine(copyConfigModule, db, cm, sm);
   }
 
   public void setSshInjector(Injector injector) {
@@ -363,6 +355,23 @@
       reattachItem(old, sysItems, newPlugin.getSysInjector(), newPlugin);
       reattachItem(old, sshItems, newPlugin.getSshInjector(), newPlugin);
       reattachItem(old, httpItems, newPlugin.getHttpInjector(), newPlugin);
+
+      apiInjector = Optional.ofNullable(newPlugin.getApiInjector()).orElse(apiInjector);
+
+      if (apiInjector != null) {
+        apiItems.putAll(dynamicItemsOf(apiInjector));
+        apiSets.putAll(dynamicSetsOf(apiInjector));
+        apiMaps.putAll(dynamicMapsOf(apiInjector));
+
+        ImmutableList<Injector> allPluginInjectors =
+            listOfInjectors(
+                newPlugin.getSysInjector(),
+                newPlugin.getSshInjector(),
+                newPlugin.getHttpInjector());
+        allPluginInjectors.forEach(i -> reattachItem(old, apiItems, i, newPlugin));
+        allPluginInjectors.forEach(i -> reattachSet(old, apiSets, i, newPlugin));
+        allPluginInjectors.forEach(i -> reattachMap(old, apiMaps, i, newPlugin));
+      }
     } finally {
       exit(oldContext);
     }
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 9e23c0b..7c7d8d5 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -18,7 +18,6 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
@@ -52,7 +51,6 @@
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Comparator;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
@@ -62,7 +60,6 @@
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
-import java.util.jar.JarFile;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 import org.eclipse.jgit.lib.Config;
 
@@ -93,6 +90,7 @@
   private final MandatoryPluginsCollection mandatoryPlugins;
   private final UniversalServerPluginProvider serverPluginFactory;
   private final GerritRuntime gerritRuntime;
+  private final PluginOrderComparator pluginOrderComparator;
 
   @Inject
   public PluginLoader(
@@ -122,6 +120,10 @@
     mandatoryPlugins = mpc;
     this.gerritRuntime = gerritRuntime;
 
+    ImmutableList<String> pluginOrderOverrides =
+        ImmutableList.copyOf(cfg.getStringList("plugins", null, "loadPriority"));
+    pluginOrderComparator = new PluginOrderComparator(pluginOrderOverrides);
+
     long checkFrequency =
         ConfigUtil.getTimeUnit(
             cfg,
@@ -221,7 +223,7 @@
     toCleanup.add(plugin);
   }
 
-  public void disablePlugins(Set<String> names) {
+  public void disablePlugins(Set<String> names) throws PluginInstallException {
     if (!isRemoteAdminEnabled()) {
       logger.atWarning().log(
           "Remote plugin administration is disabled, ignoring disablePlugins(%s)", names);
@@ -235,6 +237,12 @@
           continue;
         }
 
+        if (active.getApiModule().isPresent()) {
+          throw new PluginInstallException(
+              String.format(
+                  "Plugin %s has registered an ApiModule therefore it cannot be disabled", name));
+        }
+
         if (mandatoryPlugins.contains(name)) {
           logger.atWarning().log("Mandatory plugin %s cannot be disabled", name);
           continue;
@@ -381,11 +389,14 @@
         try {
           logger.atInfo().log("Reloading plugin %s", name);
           Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
-          logger.atInfo().log(
-              "Reloaded plugin %s%s, version %s",
-              newPlugin.getName(),
-              newPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
-              newPlugin.getVersion());
+
+          if (newPlugin != active) {
+            logger.atInfo().log(
+                "Reloaded plugin %s%s, version %s",
+                newPlugin.getName(),
+                newPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
+                newPlugin.getVersion());
+          }
         } catch (PluginInstallException e) {
           logger.atWarning().withCause(e.getCause()).log("Cannot reload plugin %s", name);
           throw e;
@@ -464,43 +475,7 @@
 
   private TreeSet<Map.Entry<String, Path>> jarsApiFirstSortedPluginsSet(
       Map<String, Path> activePlugins) {
-    TreeSet<Map.Entry<String, Path>> sortedPlugins =
-        Sets.newTreeSet(
-            new Comparator<Map.Entry<String, Path>>() {
-              @Override
-              public int compare(Map.Entry<String, Path> e1, Map.Entry<String, Path> e2) {
-                Path n1 = e1.getValue().getFileName();
-                Path n2 = e2.getValue().getFileName();
-
-                try {
-                  boolean e1IsApi = isApi(e1.getValue());
-                  boolean e2IsApi = isApi(e2.getValue());
-                  return ComparisonChain.start()
-                      .compareTrueFirst(e1IsApi, e2IsApi)
-                      .compareTrueFirst(isJar(n1), isJar(n2))
-                      .compare(n1, n2)
-                      .result();
-                } catch (IOException ioe) {
-                  logger.atSevere().withCause(ioe).log("Unable to compare %s and %s", n1, n2);
-                  return 0;
-                }
-              }
-
-              private boolean isJar(Path pluginPath) {
-                return pluginPath.toString().endsWith(".jar");
-              }
-
-              private boolean isApi(Path pluginPath) throws IOException {
-                return isJar(pluginPath) && hasApiModuleEntryInManifest(pluginPath);
-              }
-
-              private boolean hasApiModuleEntryInManifest(Path pluginPath) throws IOException {
-                try (JarFile jarFile = new JarFile(pluginPath.toFile())) {
-                  return !Strings.isNullOrEmpty(
-                      jarFile.getManifest().getMainAttributes().getValue(ServerPlugin.API_MODULE));
-                }
-              }
-            });
+    TreeSet<Map.Entry<String, Path>> sortedPlugins = Sets.newTreeSet(pluginOrderComparator);
 
     addAllEntries(activePlugins, sortedPlugins);
     return sortedPlugins;
@@ -522,6 +497,13 @@
         return oldPlugin;
       }
 
+      if (oldPlugin != null && oldPlugin.getApiModule().isPresent()) {
+        throw new PluginInstallException(
+            String.format(
+                "Plugin %s has registered an ApiModule therefore its restart/reload is not allowed",
+                name));
+      }
+
       Plugin newPlugin = loadPlugin(name, plugin, snapshot);
       if (newPlugin.getCleanupHandle() != null) {
         cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
@@ -573,7 +555,13 @@
       }
     }
     for (String name : unload) {
-      unloadPlugin(running.get(name));
+      Plugin runningPlugin = running.get(name);
+
+      if (runningPlugin.getApiModule().isPresent()) {
+        logger.atWarning().log("Cannot remove plugin %s as it has registered an ApiModule", name);
+      } else {
+        unloadPlugin(running.get(name));
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/plugins/PluginOrderComparator.java b/java/com/google/gerrit/server/plugins/PluginOrderComparator.java
new file mode 100644
index 0000000..b0b6649
--- /dev/null
+++ b/java/com/google/gerrit/server/plugins/PluginOrderComparator.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+class PluginOrderComparator implements Comparator<Map.Entry<String, Path>> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ManifestLoader DEFAULT_LOADER =
+      pluginPath -> {
+        try (JarFile jarFile = new JarFile(pluginPath.toFile())) {
+          return jarFile.getManifest();
+        }
+      };
+
+  @FunctionalInterface
+  interface ManifestLoader {
+    Manifest load(Path pluginPath) throws IOException;
+  }
+
+  private final ManifestLoader manifestLoader;
+  private final ImmutableList<String> pluginLoadOrderOverrides;
+
+  PluginOrderComparator(ImmutableList<String> pluginLoadOrderOverrides) {
+    this(pluginLoadOrderOverrides, DEFAULT_LOADER);
+  }
+
+  PluginOrderComparator(
+      ImmutableList<String> pluginLoadOrderOverrides, ManifestLoader manifestLoader) {
+    this.manifestLoader = manifestLoader;
+    this.pluginLoadOrderOverrides = pluginLoadOrderOverrides;
+  }
+
+  @Override
+  public int compare(Map.Entry<String, Path> e1, Map.Entry<String, Path> e2) {
+    Path n1 = e1.getValue().getFileName();
+    Path n2 = e2.getValue().getFileName();
+
+    try {
+      boolean e1IsApi = isApi(e1.getValue());
+      boolean e2IsApi = isApi(e2.getValue());
+      return ComparisonChain.start()
+          .compareTrueFirst(e1IsApi, e2IsApi)
+          .compareTrueFirst(isJar(n1), isJar(n2))
+          .compare(loadOrderOverrides(e1.getKey()), loadOrderOverrides(e2.getKey()))
+          .compare(n1, n2)
+          .result();
+    } catch (IOException ioe) {
+      logger.atSevere().withCause(ioe).log("Unable to compare %s and %s", n1, n2);
+      return 0;
+    }
+  }
+
+  private boolean isJar(Path pluginPath) {
+    return pluginPath.toString().endsWith(".jar");
+  }
+
+  private boolean isApi(Path pluginPath) throws IOException {
+    return isJar(pluginPath) && hasApiModuleEntryInManifest(pluginPath);
+  }
+
+  private boolean hasApiModuleEntryInManifest(Path pluginPath) throws IOException {
+    return !Strings.isNullOrEmpty(
+        manifestLoader.load(pluginPath).getMainAttributes().getValue(ServerPlugin.API_MODULE));
+  }
+
+  private int loadOrderOverrides(String pluginName) {
+    int pluginNameIndex = pluginLoadOrderOverrides.indexOf(pluginName);
+    if (pluginNameIndex > -1) {
+      return pluginNameIndex - pluginLoadOrderOverrides.size();
+    }
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index c275ff9..fb7cbe2 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -58,7 +58,6 @@
   private Injector sshInjector;
   private Injector httpInjector;
   private LifecycleManager serverManager;
-  private List<ReloadableRegistrationHandle<?>> reloadableHandles;
 
   private Optional<Module> apiModule = Optional.empty();
 
@@ -172,6 +171,11 @@
   @Override
   protected boolean canReload() {
     Attributes main = manifest.getMainAttributes();
+    String apiModule = main.getValue(API_MODULE);
+    if (apiModule != null) {
+      return false;
+    }
+
     String v = main.getValue("Gerrit-ReloadMode");
     if (Strings.isNullOrEmpty(v) || "reload".equalsIgnoreCase(v)) {
       return true;
@@ -281,7 +285,7 @@
     modules.add(new ServerPluginInfoModule(this, env.getServerMetrics()));
     return apiInjector
         .map(injector -> injector.createChildInjector(modules))
-        .orElse(Guice.createInjector(modules));
+        .orElseGet(() -> Guice.createInjector(modules));
   }
 
   private Injector newRootInjectorWithApiModule(
diff --git a/java/com/google/gerrit/server/plugins/TestServerPlugin.java b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
index cd5d5e3..a7ca88f 100644
--- a/java/com/google/gerrit/server/plugins/TestServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
@@ -34,13 +34,27 @@
       String sshName,
       Path dataDir)
       throws InvalidPluginException {
+    this(name, pluginCanonicalWebUrl, user, null, classLoader, sysName, httpName, sshName, dataDir);
+  }
+
+  public TestServerPlugin(
+      String name,
+      String pluginCanonicalWebUrl,
+      PluginUser user,
+      PluginContentScanner scanner,
+      ClassLoader classLoader,
+      String sysName,
+      String httpName,
+      String sshName,
+      Path dataDir)
+      throws InvalidPluginException {
     super(
         name,
         pluginCanonicalWebUrl,
         user,
         null,
         null,
-        null,
+        scanner,
         dataDir,
         classLoader,
         null,
@@ -83,9 +97,4 @@
   public void stop(PluginGuiceEnvironment env) {
     super.stop(env);
   }
-
-  @Override
-  public PluginContentScanner getContentScanner() {
-    return null;
-  }
 }
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index ab134b5..c3d3978 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -112,7 +112,9 @@
                 RefPermission.READ.describeForException()));
         throw e;
       }
-    } else if (object instanceof RevTag) {
+      return;
+    }
+    if (object instanceof RevTag) {
       RevTag tag = (RevTag) object;
       try (RevWalk rw = new RevWalk(repo)) {
         rw.parseBody(tag);
@@ -145,7 +147,11 @@
       } else {
         forRef.check(RefPermission.CREATE_TAG);
       }
+      return;
     }
+    throw new AuthException(
+        String.format(
+            "Ref creation not allowed. Object %s is neither Commit or Tag.", object.getId()));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java b/java/com/google/gerrit/server/project/DefaultLockManager.java
similarity index 71%
rename from java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
rename to java/com/google/gerrit/server/project/DefaultLockManager.java
index 762e244..ab1148e 100644
--- a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
+++ b/java/com/google/gerrit/server/project/DefaultLockManager.java
@@ -15,28 +15,26 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.util.concurrent.Striped;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
 import java.util.concurrent.locks.Lock;
 
-/** In-memory lock for project names. */
+/** In-memory lock manager */
 @Singleton
-public class DefaultProjectNameLockManager implements ProjectNameLockManager {
+public class DefaultLockManager implements LockManager {
 
-  public static class DefaultProjectNameLockManagerModule extends AbstractModule {
+  public static class DefaultLockManagerModule extends AbstractModule {
     @Override
     protected void configure() {
-      DynamicItem.bind(binder(), ProjectNameLockManager.class)
-          .to(DefaultProjectNameLockManager.class);
+      DynamicItem.bind(binder(), LockManager.class).to(DefaultLockManager.class);
     }
   }
 
   Striped<Lock> locks = Striped.lock(10);
 
   @Override
-  public Lock getLock(Project.NameKey name) {
+  public Lock getLock(String name) {
     return locks.get(name);
   }
 }
diff --git a/java/com/google/gerrit/server/project/LabelConfigValidator.java b/java/com/google/gerrit/server/project/LabelConfigValidator.java
index 33e0a87..c4bc9fb 100644
--- a/java/com/google/gerrit/server/project/LabelConfigValidator.java
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -352,7 +353,7 @@
 
   private Config loadNewConfig(CommitReceivedEvent receiveEvent)
       throws IOException, ConfigInvalidException {
-    ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile bareConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
     return bareConfig.getConfig();
   }
@@ -364,8 +365,7 @@
     }
 
     try {
-      ProjectLevelConfig.Bare bareConfig =
-          new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+      VersionedConfigFile bareConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
       bareConfig.load(
           receiveEvent.project.getNameKey(),
           receiveEvent.revWalk,
diff --git a/java/com/google/gerrit/server/project/ProjectNameLockManager.java b/java/com/google/gerrit/server/project/LockManager.java
similarity index 67%
rename from java/com/google/gerrit/server/project/ProjectNameLockManager.java
rename to java/com/google/gerrit/server/project/LockManager.java
index f67dd04..8a85b32 100644
--- a/java/com/google/gerrit/server/project/ProjectNameLockManager.java
+++ b/java/com/google/gerrit/server/project/LockManager.java
@@ -14,17 +14,19 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.entities.Project;
 import java.util.concurrent.locks.Lock;
 
 /**
- * A per-repo lock mechanism.
- *
- * <p>This ensures that project creation (repo creation, config creation, first commit) is atomic,
- * and can be used to separate creation and deletion in the delete-project plugin.
+ * A global locking mechanism.
  *
  * <p>This is an interface because distributed setup may need something beyond an in-memory lock.
+ *
+ * <p>A Gerrit system consisting of a single Gerrit server only needs an in-memory lock manager
+ * which is implemented by the DefaultLockManager.
+ *
+ * <p>A distributed setup, consisting of more than one Gerrit server, can implement a distributed
+ * lock manager that provides global locks.
  */
-public interface ProjectNameLockManager {
-  public Lock getLock(Project.NameKey name);
+public interface LockManager {
+  public Lock getLock(String name);
 }
diff --git a/java/com/google/gerrit/server/project/PeriodicProjectIndexer.java b/java/com/google/gerrit/server/project/PeriodicProjectIndexer.java
new file mode 100644
index 0000000..8f40a39
--- /dev/null
+++ b/java/com/google/gerrit/server/project/PeriodicProjectIndexer.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.collect.Sets;
+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.gerrit.entities.Project;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.project.ProjectIndexer;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+public class PeriodicProjectIndexer implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager gitRepoManager;
+  private final ProjectIndexer indexer;
+  private ListeningExecutorService executor;
+  private final ProjectIndexCollection indexes;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  PeriodicProjectIndexer(
+      GitRepositoryManager gitRepoManager,
+      ProjectIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      ProjectIndexCollection indexes,
+      IndexConfig indexConfig) {
+    this.gitRepoManager = gitRepoManager;
+    this.indexer = indexer;
+    this.executor = executor;
+    this.indexes = indexes;
+    this.indexConfig = indexConfig;
+  }
+
+  @Override
+  public void run() {
+    logger.atInfo().log("reindexing projects");
+    Set<Project.NameKey> gitRepos = gitRepoManager.list();
+    List<ListenableFuture<?>> indexingTasks = new ArrayList<>();
+    for (Project.NameKey n : gitRepos) {
+      indexingTasks.add(executor.submit(() -> indexer.index(n)));
+    }
+    try {
+      Futures.successfulAsList(indexingTasks).get();
+    } catch (InterruptedException | ExecutionException e) {
+      logger.atSevere().log("Error while reindexing projects");
+      return;
+    }
+
+    Set<Project.NameKey> projectsInIndex;
+    try {
+      DataSource<ProjectData> result =
+          indexes
+              .getSearchIndex()
+              .getSource(
+                  Predicate.any(),
+                  QueryOptions.create(
+                      indexConfig, 0, Integer.MAX_VALUE, Set.of(ProjectField.NAME_FIELD.name())));
+      projectsInIndex =
+          StreamSupport.stream(result.readRaw().spliterator(), false)
+              .map(f -> fromIdField(f))
+              .collect(Collectors.toUnmodifiableSet());
+    } catch (QueryParseException e) {
+      throw new RuntimeException(e);
+    }
+
+    for (Project.NameKey n : Sets.difference(projectsInIndex, gitRepos)) {
+      logger.atInfo().log("removing non-existing project %s from index", n);
+      indexer.index(n);
+    }
+  }
+
+  private static Project.NameKey fromIdField(FieldBundle f) {
+    return Project.nameKey(f.<String>getValue(ProjectField.NAME_SPEC));
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index b940ccc..4bd195c 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -141,7 +141,7 @@
             .maximumWeight(0);
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
-            .maximumWeight(1)
+            .maximumWeight(4)
             .loader(Lister.class);
 
         bind(ProjectCacheImpl.class);
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 8256198..7ab7629 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -18,60 +18,15 @@
 
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.ImmutableConfig;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
-import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.eclipse.jgit.annotations.Nullable;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 
 /** Configuration file in the projects refs/meta/config branch. */
 public class ProjectLevelConfig {
-  /**
-   * This class is a low-level API that allows callers to read the config directly from a repository
-   * and make updates to it.
-   */
-  public static class Bare extends VersionedMetaData {
-    private final String fileName;
-    @Nullable private Config cfg;
-
-    public Bare(String fileName) {
-      this.fileName = fileName;
-      this.cfg = null;
-    }
-
-    public Config getConfig() {
-      if (cfg == null) {
-        cfg = new Config();
-      }
-      return cfg;
-    }
-
-    @Override
-    protected String getRefName() {
-      return RefNames.REFS_CONFIG;
-    }
-
-    @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
-      cfg = readConfig(fileName);
-    }
-
-    @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException {
-      if (commit.getMessage() == null || "".equals(commit.getMessage())) {
-        commit.setMessage("Updated configuration\n");
-      }
-      saveConfig(fileName, cfg);
-      return true;
-    }
-  }
-
   private final String fileName;
   private final ProjectState project;
   private final ImmutableConfig immutableConfig;
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index e8ecaa2..c31cd35 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -74,7 +74,7 @@
       Collection<Ref> filtered =
           optionalUserProvider
               .map(permissionBackend::user)
-              .orElse(permissionBackend.currentUser())
+              .orElseGet(() -> permissionBackend.currentUser())
               .project(project)
               .filter(refs, repo, RefFilterOptions.defaults());
       List<RevCommit> visible = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 3fda87a..ea62d7d 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -45,10 +46,16 @@
    *
    * @throws AuthException if this user is not allowed to remove this approval.
    * @throws PermissionBackendException on failure of permission checks.
+   * @throws ResourceConflictException if the approval cannot be removed because the change is
+   *     merged
    */
   public void checkRemoveReviewer(
       ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
-      throws PermissionBackendException, AuthException {
+      throws PermissionBackendException, AuthException, ResourceConflictException {
+    if (notes.getChange().isMerged() && approval.value() != 0) {
+      throw new ResourceConflictException("cannot remove votes from merged change");
+    }
+
     checkRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
   }
 
@@ -82,35 +89,43 @@
   public boolean testRemoveReviewer(
       ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
       throws PermissionBackendException {
-    if (canRemoveReviewerWithoutPermissionCheck(
-        permissionBackend, cd.change(), currentUser, reviewer, value)) {
-      return true;
-    }
-    return permissionBackend.user(currentUser).change(cd).test(ChangePermission.REMOVE_REVIEWER);
-  }
-
-  private void checkRemoveReviewer(
-      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int val)
-      throws PermissionBackendException, AuthException {
-    if (canRemoveReviewerWithoutPermissionCheck(
-        permissionBackend, notes.getChange(), currentUser, reviewer, val)) {
-      return;
-    }
-
-    permissionBackend.user(currentUser).change(notes).check(ChangePermission.REMOVE_REVIEWER);
-  }
-
-  private static boolean canRemoveReviewerWithoutPermissionCheck(
-      PermissionBackend permissionBackend,
-      Change change,
-      CurrentUser currentUser,
-      Account.Id reviewer,
-      int value)
-      throws PermissionBackendException {
-    if (change.isMerged() && value != 0) {
+    if (cd.change().isMerged() && value != 0) {
       return false;
     }
 
+    if (canRemoveReviewerWithoutPermissionCheck(cd.change(), currentUser, reviewer, value)) {
+      return true;
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
+    PermissionBackend.ForProject forProject = withUser.project(cd.project());
+    return (forProject.ref(cd.change().getDest().branch()).test(RefPermission.WRITE_CONFIG)
+            || withUser.test(GlobalPermission.ADMINISTRATE_SERVER))
+        || withUser.change(cd).test(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  private void checkRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws PermissionBackendException, AuthException {
+    if (canRemoveReviewerWithoutPermissionCheck(notes.getChange(), currentUser, reviewer, value)) {
+      return;
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
+    PermissionBackend.ForProject forProject = withUser.project(notes.getProjectName());
+    if (forProject.ref(notes.getChange().getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
+      return;
+    }
+    permissionBackend.user(currentUser).change(notes).check(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  public static boolean canRemoveReviewerWithoutPermissionCheck(
+      Change change, CurrentUser currentUser, Account.Id reviewer, int value) {
     if (currentUser.isIdentifiedUser()) {
       Account.Id aId = currentUser.getAccountId();
       if (aId.equals(reviewer)) {
@@ -120,14 +135,6 @@
       }
     }
 
-    // Users with the remove reviewer permission, the branch owner, project
-    // owner and site admin can remove anyone
-    PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
-    PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
-        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
-      return true;
-    }
     return false;
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 7f73cd3..ec376e0 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -186,7 +186,12 @@
     }
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
     for (Label label : record.labels) {
-      if (skipSubmitRequirementFor(label)) {
+      if (skipSubmitRequirementFor(label)
+          ||
+          // If SubmitRecord is a PASS, then skip all the requirements
+          // that are not a PASS as they would block the overall submit requirement
+          // status from being a PASS
+          (mapStatus(record) == Status.PASS && mapStatus(label) != Status.PASS)) {
         continue;
       }
       String expressionString = String.format("label:%s=%s", label.label, ruleName);
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 9af80bd..aab1cc5 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -15,20 +15,17 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.Streams;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.index.OnlineReindexMode;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -80,7 +77,6 @@
   private final PluginSetContext<SubmitRule> submitRules;
   private final Metrics metrics;
   private final SubmitRuleOptions opts;
-  private final CallerFinder callerFinder;
 
   @Inject
   private SubmitRuleEvaluator(
@@ -95,14 +91,6 @@
     this.metrics = metrics;
 
     this.opts = options;
-
-    this.callerFinder =
-        CallerFinder.builder()
-            .addTarget(ChangeApi.class)
-            .addTarget(ChangeJson.class)
-            .addTarget(ChangeData.class)
-            .addTarget(SubmitRequirementsEvaluatorImpl.class)
-            .build();
   }
 
   /**
@@ -116,10 +104,7 @@
     try (TraceTimer timer =
             TraceContext.newTimer(
                 "Evaluate submit rules",
-                Metadata.builder()
-                    .changeId(cd.change().getId().get())
-                    .caller(callerFinder.findCaller())
-                    .build());
+                Metadata.builder().changeId(cd.change().getId().get()).build());
         Timer0.Context ignored = metrics.submitRuleEvaluationLatency.start()) {
       if (cd.change() == null) {
         throw new StorageException("Change not found");
@@ -155,7 +140,7 @@
           // Skip evaluating the default submit rule if the project has prolog rules.
           // Note that in this case, the prolog submit rule will handle labels for us
           .filter(
-              projectState.hasPrologRules()
+              projectState.hasPrologRules() && prologSubmitRuleUtil.isProjectRulesEnabled()
                   ? rule -> !(rule.get() instanceof DefaultSubmitRule)
                   : rule -> true)
           .map(
@@ -184,14 +169,10 @@
    */
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
     try (Timer0.Context ignored = metrics.submitTypeEvaluationLatency.start()) {
-      try {
-        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);
+      ProjectState projectState =
+          projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
+      if (!prologSubmitRuleUtil.isProjectRulesEnabled()) {
+        return SubmitTypeRecord.OK(projectState.getSubmitType());
       }
 
       return prologSubmitRuleUtil.getSubmitType(cd);
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 32b87ec..989b354 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project.testing;
 
-import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
@@ -49,7 +48,7 @@
     LabelType.Builder label =
         labelBuilder(
             "Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
-    label.setFunction(LabelFunction.PATCH_SET_LOCK);
+    label.setPatchSetLockFunction();
     return label.build();
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 5676ab4..d153bc9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
@@ -78,7 +77,6 @@
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.SkipCurrentRulesEvaluationOnClosedChanges;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -110,6 +108,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
@@ -248,11 +247,11 @@
     }
 
     public ChangeData create(Project.NameKey project, Change.Id id) {
-      return assistedFactory.create(project, id, null, null);
+      return assistedFactory.create(project, id, null, null, null);
     }
 
     public ChangeData create(Project.NameKey project, Change.Id id, ObjectId metaRevision) {
-      ChangeData cd = assistedFactory.create(project, id, null, null);
+      ChangeData cd = assistedFactory.create(project, id, null, null, null);
       cd.setMetaRevision(metaRevision);
       return cd;
     }
@@ -265,19 +264,29 @@
     }
 
     public ChangeData create(Change change) {
-      return assistedFactory.create(change.getProject(), change.getId(), change, null);
+      return create(change, null);
+    }
+
+    public ChangeData create(Change change, Change.Id virtualId) {
+      return assistedFactory.create(
+          change.getProject(),
+          change.getId(),
+          !Objects.equals(virtualId, change.getId()) ? virtualId : null,
+          change,
+          null);
     }
 
     public ChangeData create(ChangeNotes notes) {
       return assistedFactory.create(
-          notes.getChange().getProject(), notes.getChangeId(), notes.getChange(), notes);
+          notes.getChange().getProject(), notes.getChangeId(), null, notes.getChange(), notes);
     }
   }
 
   public interface AssistedFactory {
     ChangeData create(
         Project.NameKey project,
-        Change.Id id,
+        @Assisted("changeId") Change.Id id,
+        @Assisted("virtualId") @Nullable Change.Id virtualId,
         @Nullable Change change,
         @Nullable ChangeNotes notes);
   }
@@ -296,7 +305,7 @@
    */
   public static ChangeData createForTest(
       Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
-    return createForTest(project, id, currentPatchSetId, commitId, null, null, null);
+    return createForTest(project, id, currentPatchSetId, commitId, null, null);
   }
 
   /**
@@ -309,7 +318,6 @@
    * @param id change ID
    * @param currentPatchSetId current patchset number
    * @param commitId commit SHA1 of the current patchset
-   * @param serverId Gerrit server id
    * @param virtualIdAlgo algorithm for virtualising the Change number
    * @param changeNotes notes associated with the Change
    * @return instance for testing.
@@ -319,7 +327,6 @@
       Change.Id id,
       int currentPatchSetId,
       ObjectId commitId,
-      @Nullable String serverId,
       @Nullable ChangeNumberVirtualIdAlgorithm virtualIdAlgo,
       @Nullable ChangeNotes changeNotes) {
     ChangeData cd =
@@ -343,12 +350,12 @@
             null,
             null,
             null,
-            serverId,
             virtualIdAlgo,
             false,
             project,
             id,
             null,
+            null,
             changeNotes);
     cd.currentPatchSet =
         PatchSet.builder()
@@ -450,12 +457,12 @@
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
   private Optional<Instant> mergedOn;
-  private ImmutableSetMultimap<NameKey, RefState> refStates;
+  private ImmutableSetMultimap<Project.NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
-  private String gerritServerId;
   private String changeServerId;
   private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
   private Boolean failedParsingFromIndex = false;
+  private Change.Id virtualId;
 
   @Inject
   private ChangeData(
@@ -478,11 +485,11 @@
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
-      @GerritServerId String gerritServerId,
       ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @SkipCurrentRulesEvaluationOnClosedChanges Boolean skipCurrentRulesEvaluationOnClosedChange,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id,
+      @Assisted("changeId") Change.Id id,
+      @Assisted("virtualId") @Nullable Change.Id virtualId,
       @Assisted @Nullable Change change,
       @Assisted @Nullable ChangeNotes notes) {
     this.approvalsUtil = approvalsUtil;
@@ -516,8 +523,8 @@
     this.notes = notes;
 
     this.changeServerId = notes == null ? null : notes.getServerId();
-    this.gerritServerId = gerritServerId;
     this.virtualIdFunc = virtualIdFunc;
+    this.virtualId = virtualId;
   }
 
   /**
@@ -648,12 +655,33 @@
     return legacyId;
   }
 
-  public Change.Id getVirtualId() {
-    if (virtualIdFunc == null || changeServerId == null || changeServerId.equals(gerritServerId)) {
-      return legacyId;
+  public static void ensureChangeServerId(Iterable<ChangeData> changes) {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
     }
 
-    return Change.id(virtualIdFunc.apply(changeServerId, legacyId.get()));
+    for (ChangeData cd : changes) {
+      var unused = cd.changeServerId();
+    }
+  }
+
+  @Nullable
+  public String changeServerId() {
+    if (changeServerId == null) {
+      if (!lazyload()) {
+        return null;
+      }
+      changeServerId = notes().getServerId();
+    }
+    return changeServerId;
+  }
+
+  public Change.Id virtualId() {
+    if (virtualId == null) {
+      return virtualIdFunc == null ? legacyId : virtualIdFunc.apply(changeServerId, legacyId);
+    }
+    return virtualId;
   }
 
   public Project.NameKey project() {
@@ -692,10 +720,10 @@
     return this;
   }
 
-  public ObjectId metaRevisionOrThrow() {
+  public Optional<ObjectId> metaRevision() {
     if (notes == null) {
       if (metaRevision != null) {
-        return metaRevision;
+        return Optional.of(metaRevision);
       }
       if (refStates != null) {
         ImmutableSet<RefState> refs = refStates.get(project);
@@ -703,17 +731,25 @@
           String metaRef = RefNames.changeMetaRef(getId());
           for (RefState r : refs) {
             if (r.ref().equals(metaRef)) {
-              return r.id();
+              return Optional.of(r.id());
             }
           }
         }
       }
-      throwIfNotLazyLoad("metaRevision");
+      if (!lazyload()) {
+        return Optional.empty();
+      }
 
       @SuppressWarnings("unused")
       var unused = notes();
     }
-    return notes.getRevision();
+    metaRevision = notes.getRevision();
+    return Optional.of(metaRevision);
+  }
+
+  public ObjectId metaRevisionOrThrow() {
+    return metaRevision()
+        .orElseThrow(() -> new IllegalStateException("'metaRevision' field not populated"));
   }
 
   boolean fastIsVisibleTo(CurrentUser user) {
@@ -1436,7 +1472,7 @@
       if (!lazyload()) {
         return ImmutableList.of();
       }
-      starAccounts = requireNonNull(starredChangesReader).byChange(legacyId);
+      starAccounts = requireNonNull(starredChangesReader).byChange(virtualId());
     }
     return starAccounts;
   }
@@ -1499,13 +1535,14 @@
     }
   }
 
-  public SetMultimap<NameKey, RefState> getRefStates() {
+  public SetMultimap<Project.NameKey, RefState> getRefStates() {
     if (refStates == null) {
       if (!lazyload()) {
         return ImmutableSetMultimap.of();
       }
 
-      ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
+      ImmutableSetMultimap.Builder<Project.NameKey, RefState> result =
+          ImmutableSetMultimap.builder();
       for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : editRefs().cellSet()) {
         result.put(
             project,
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
index 726a376..ecd1fe2 100644
--- a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
@@ -16,7 +16,12 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
@@ -38,9 +43,11 @@
       Integer.BYTES * 8 - CHANGE_NUM_BIT_LEN; // Allows up to 64 ServerIds
 
   private final ImmutableMap<String, Integer> serverIdCodes;
+  private final String localServerId;
 
   @Inject
   public ChangeNumberBitmapMaskAlgorithm(
+      @GerritServerId String localServerId,
       @GerritImportedServerIds ImmutableList<String> importedServerIds) {
     if (importedServerIds.size() >= 1 << SERVER_ID_BIT_LEN) {
       throw new ProvisionException(
@@ -54,23 +61,34 @@
     }
 
     serverIdCodes = serverIdCodesBuilder.build();
+    this.localServerId = localServerId;
   }
 
   @Override
-  public int apply(String changeServerId, int changeNum) {
-    if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
-      throw new IllegalArgumentException(
-          String.format(
-              "Change number %d is too large to be converted into a virtual id", changeNum));
+  public Change.Id apply(String changeServerId, Change.Id changeNumId) {
+    if (changeServerId == null || localServerId.equals(changeServerId)) {
+      return changeNumId;
     }
 
-    Integer encodedServerId = serverIdCodes.get(changeServerId);
-    if (encodedServerId == null) {
-      throw new IllegalArgumentException(
-          String.format("ServerId %s is not part of the GerritImportedServerIds", changeServerId));
-    }
-    int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
+    int changeNum = changeNumId.get();
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "ChangeNumberBitmapMaskAlgorithm", Metadata.builder().changeId(changeNum).build())) {
+      if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
+        throw new IllegalArgumentException(
+            String.format(
+                "Change number %d is too large to be converted into a virtual id", changeNum));
+      }
 
-    return virtualId;
+      Integer encodedServerId = serverIdCodes.get(changeServerId);
+      if (encodedServerId == null) {
+        throw new IllegalArgumentException(
+            String.format(
+                "ServerId %s is not part of the GerritImportedServerIds", changeServerId));
+      }
+      int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
+
+      return Change.id(virtualId);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
index ab21705..6daf16f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.inject.ImplementedBy;
 
 /**
@@ -31,5 +32,5 @@
    * @param legacyChangeNum legacy change number
    * @return virtual id which combines serverId and legacyChangeNum together
    */
-  int apply(String serverId, int legacyChangeNum);
+  Change.Id apply(String serverId, Change.Id legacyChangeNum);
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 587f2de..d5f9c5b 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -87,7 +88,10 @@
   /**
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
+   *
+   * <p>The predicates filter by "legacy_id_str" field.
    */
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static Predicate<ChangeData> draftBy(
       DraftCommentsReader draftCommentsReader, Account.Id id) {
     ImmutableSet<Predicate<ChangeData>> changeIdPredicates =
@@ -102,7 +106,10 @@
   /**
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
+   *
+   * <p>The predicates filter by "legacy_id_str" field.
    */
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static Predicate<ChangeData> starBy(
       StarredChangesReader starredChangesReader, Account.Id id) {
     ImmutableSet<Predicate<ChangeData>> starredChanges =
@@ -144,6 +151,19 @@
   }
 
   /**
+   * Returns a predicate that matches the change number with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
+  public static Predicate<ChangeData> changeNumber(
+      Change.Id id, ChangeQueryBuilder.Arguments args) {
+    if (args.getSchema().hasField(ChangeField.CHANGENUM_SPEC)) {
+      return new ChangeIndexCardinalPredicate(
+          ChangeField.CHANGENUM_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
+    }
+    return idStr(id);
+  }
+
+  /**
    * Returns a predicate that matches changes owned by the provided {@link
    * com.google.gerrit.entities.Account.Id}.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index a64b68d..9b85582 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -144,7 +144,7 @@
 
   public interface ChangeIsOperandFactory extends ChangeOperandFactory {}
 
-  private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
+  private static final Pattern PAT_CHANGE_NUMBER = Pattern.compile("^[1-9][0-9]*$");
   private static final Pattern PAT_PROJECT_CHANGE_NUM = Pattern.compile("^([^~]+)~([1-9][0-9]*)$");
   private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
   private static final Pattern DEF_CHANGE =
@@ -166,6 +166,7 @@
 
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
+  public static final String FIELD_CHANGE_NUMBER = "changenumber";
   public static final String FIELD_COMMENT = "comment";
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
@@ -235,6 +236,7 @@
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
   public static final String ARG_ID_NON_CONTRIBUTOR = "non_contributor";
   public static final String ARG_COUNT = "count";
+  public static final String ARG_USERS = "users";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
   public static final Account.Id NON_CONTRIBUTOR_ACCOUNT_ID = Account.id(-2);
@@ -588,6 +590,18 @@
 
   @Operator
   public Predicate<ChangeData> change(String query) throws QueryParseException {
+    return getPredicateChangeData(query, changeId -> ChangePredicates.changeNumber(changeId, args));
+  }
+
+  // Keep using the index legacy document-id (legacy_id_str) for URLs queries like: "/q/123456",
+  // "/q/Iasdw2312321", "/q/project~123456"  that are expecting to always find a single element.
+  private Predicate<ChangeData> defaultSearch(String query) throws QueryParseException {
+    return getPredicateChangeData(query, ChangePredicates::idStr);
+  }
+
+  private Predicate<ChangeData> getPredicateChangeData(
+      String query, Function<Change.Id, Predicate<ChangeData>> changePredicateGetter)
+      throws QueryParseException {
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
     if (triplet.isPresent()) {
       return Predicate.and(
@@ -601,11 +615,10 @@
       return Predicate.and(
           project(projectChangeNumber.group(1)),
           ChangePredicates.idStr(projectChangeNumber.group(2)));
-
-    } else if (PAT_LEGACY_ID.matcher(query).matches()) {
+    } else if (PAT_CHANGE_NUMBER.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return ChangePredicates.idStr(Change.id(id));
+        return changePredicateGetter.apply(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return ChangePredicates.idPrefix(parseChangeId(query));
@@ -1096,6 +1109,9 @@
                     "count=%d is not allowed. Maximum allowed value for count is %d.",
                     count, LabelPredicate.MAX_COUNT));
           }
+        } else if (key.equalsIgnoreCase(ARG_USERS)) {
+          throw new QueryParseException(
+              String.format("Cannot use the '%s' argument in search", ARG_USERS));
         } else {
           throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
         }
@@ -1690,13 +1706,13 @@
     } else if (DEF_CHANGE.matcher(query).matches()) {
       List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(2);
       try {
-        predicates.add(change(query));
+        predicates.add(defaultSearch(query));
       } catch (QueryParseException e) {
         // Skip.
       }
 
-      // For PAT_LEGACY_ID, it may also be the prefix of some commits.
-      if (query.length() >= 6 && PAT_LEGACY_ID.matcher(query).matches()) {
+      // For PAT_CHANGE_NUMBER, it may also be the prefix of some commits.
+      if (query.length() >= 6 && PAT_CHANGE_NUMBER.matcher(query).matches()) {
         predicates.add(commit(query));
       }
 
@@ -1860,12 +1876,12 @@
   }
 
   private List<ChangeData> parseChangeData(String value) throws QueryParseException {
-    if (PAT_LEGACY_ID.matcher(value).matches()) {
+    if (PAT_CHANGE_NUMBER.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return args.queryProvider.get().byLegacyChangeId(id.get());
+      return args.queryProvider.get().byChangeNumber(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
       List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index fc4c1d0..3bb42dc 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
@@ -89,7 +88,7 @@
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(ChangePredicates.project(c.getProject()));
     and.add(ChangePredicates.ref(c.getDest().branch()));
-    and.add(Predicate.not(ChangePredicates.idStr(c.getId())));
+    and.add(Predicate.not(ChangePredicates.changeNumber(c.getId(), args)));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
@@ -148,7 +147,7 @@
             "Merge failure checking conflicts of change %s in %s (%s): %s",
             id,
             firstNonNull(otherProject, "unknown project"),
-            lazy(() -> finalOther != null ? finalOther.name() : "unknown commit"),
+            finalOther != null ? finalOther.name() : "unknown commit",
             e.getMessage());
         return false;
       }
@@ -237,9 +236,9 @@
         warnWithOccasionalStackTrace(
             e,
             "Failure when loading conflicts of change %s in %s (%s): %s",
-            lazy(changeData::getId),
-            lazy(() -> firstNonNull(otherChange.getProject(), "unknown project")),
-            lazy(() -> other != null ? other.name() : "unknown commit"),
+            changeData.getId(),
+            firstNonNull(otherChange.getProject(), "unknown project"),
+            other != null ? other.name() : "unknown commit",
             e.getMessage());
         return false;
       }
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
index 45a723b..aba6a98 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
@@ -137,6 +137,8 @@
       Change c = cd.change();
       if (c == null) {
         // The change has disappeared.
+        logger.atFine().log(
+            "%s=%s doesn't match because the change has disappeared.", label, expVal);
         return false;
       }
 
@@ -145,17 +147,28 @@
         // 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.
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because the count was specified as 0 which is not"
+                + " supported.",
+            label, expVal, cd.change().getChangeId());
         return false;
       }
 
       Optional<ProjectState> project = projectCache.get(c.getDest().project());
       if (!project.isPresent()) {
         // The project has disappeared.
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because its project %s has disappeared.",
+            label, expVal, cd.change().getChangeId(), c.getDest().project().get());
         return false;
       }
 
       LabelType labelType = type(project.get().getLabelTypes(), label);
       if (labelType == null) {
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because the label is not defined by its project %s"
+                + " (label type = %s)",
+            label, expVal, cd.change().getChangeId(), project.get(), project.get().getLabelTypes());
         return false; // Label is not defined by this project.
       }
 
@@ -171,16 +184,51 @@
           }
         }
       }
+      logger.atFine().log(
+          "found %s matching votes for %s=%s on change %s (current approvals = %s)",
+          matchingVotes, label, expVal, cd.change().getChangeId(), cd.currentApprovals());
       cd.setStorageConstraint(currentStorageConstraint);
       if (!hasVote && expVal == 0) {
+        logger.atFine().log(
+            "%s=%s matches change %s because there is no vote for label %s",
+            label, expVal, cd.change().getChangeId(), label);
         return true;
       }
 
-      return count == null ? matchingVotes >= 1 : matchingVotes == count;
+      if (count == null) {
+        if (matchingVotes >= 1) {
+          logger.atFine().log(
+              "%s=%s matches change %s because there are %s matching votes (count was not"
+                  + " specified, hence 1 or more votes are needed)",
+              label, expVal, cd.change().getChangeId(), matchingVotes);
+          return true;
+        }
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because there are no matching votes (count was not"
+                + " specified, hence 1 or more votes are needed)",
+            label, expVal, cd.change().getChangeId());
+        return false;
+      }
+
+      if (matchingVotes == count) {
+        logger.atFine().log(
+            "%s=%s matches change %s because there are %s matching votes which matches the"
+                + " expected count %s",
+            label, expVal, cd.change().getChangeId(), matchingVotes, count);
+        return true;
+      }
+      logger.atFine().log(
+          "%s=%s doesn't match change %s because there are %s matching votes which doesn't match"
+              + " the expected count %s",
+          label, expVal, cd.change().getChangeId(), matchingVotes, count);
+      return false;
     }
 
     private boolean match(ChangeData cd, PatchSetApproval psa) {
       if (psa.value() != expVal) {
+        logger.atFine().log(
+            "vote %s on change %s doesn't match expected value %s",
+            psa, cd.change().getChangeId(), expVal);
         return false;
       }
       Account.Id approver = psa.accountId();
@@ -188,18 +236,27 @@
       if (account != null) {
         // case when account in query is numeric
         if (!account.equals(approver) && !isMagicUser()) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match expected approver %s",
+              psa, cd.change().getChangeId(), account);
           return false;
         }
 
         // case when account in query = owner
         if (account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
             && !cd.change().getOwner().equals(approver)) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match since it is not from the change owner %s",
+              psa, cd.change().getChangeId(), cd.change().getOwner());
           return false;
         }
 
         // case when account in query = non_uploader
         if (account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
             && cd.currentPatchSet().uploader().equals(approver)) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match since it is not from the uploader %s",
+              psa, cd.change().getChangeId(), cd.currentPatchSet().uploader());
           return false;
         }
 
@@ -207,6 +264,14 @@
           if ((cd.currentPatchSet().uploader().equals(approver)
               || matchAccount(cd.getCommitter().getEmailAddress(), approver)
               || matchAccount(cd.getAuthor().getEmailAddress(), approver))) {
+            logger.atFine().log(
+                "vote %s on change %s doesn't match since it is not from a contributor"
+                    + " (uploader: %s, committer: %s, author: %s)",
+                psa,
+                cd.change().getChangeId(),
+                cd.currentPatchSet().uploader(),
+                cd.getCommitter().getEmailAddress(),
+                cd.getAuthor().getEmailAddress());
             return false;
           }
         }
@@ -214,6 +279,10 @@
 
       IdentifiedUser reviewer = userFactory.create(approver);
       if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
+        logger.atFine().log(
+            "vote %s on change %s doesn't match since the approver %s is not a member of the"
+                + " expected group %s",
+            psa, cd.change().getChangeId(), approver, group);
         return false;
       }
 
@@ -221,12 +290,19 @@
       try {
         PermissionBackend.ForChange perm = permissionBackend.absentUser(approver).change(cd);
         if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match since the project %s doesn't permit read",
+              psa, cd.change().getChangeId(), cd.project().get());
           return false;
         }
 
         perm.check(ChangePermission.READ);
+        logger.atFine().log("vote %s on change %s matches", psa, cd.change().getChangeId());
         return true;
       } catch (PermissionBackendException | AuthException e) {
+        logger.atFine().log(
+            "vote %s on change %s doesn't match because the approver %s has no read access",
+            psa, cd.change().getChangeId(), approver);
         return false;
       }
     }
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index f3eda15..a8ee4bc 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -88,6 +88,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
   private final EditByPredicateProvider editByPredicateProvider;
+  private final Provider<ChangeQueryBuilder.Arguments> queryBuilderArgsProvider;
 
   @Inject
   InternalChangeQuery(
@@ -96,11 +97,13 @@
       IndexConfig indexConfig,
       ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory notesFactory,
-      EditByPredicateProvider editByPredicateProvider) {
+      EditByPredicateProvider editByPredicateProvider,
+      Provider<ChangeQueryBuilder.Arguments> queryBuilderArgsProvider) {
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
     this.editByPredicateProvider = editByPredicateProvider;
+    this.queryBuilderArgsProvider = queryBuilderArgsProvider;
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -115,6 +118,10 @@
     return query(ChangePredicates.idStr(id));
   }
 
+  public List<ChangeData> byChangeNumber(Change.Id id) {
+    return query(ChangePredicates.changeNumber(id, queryBuilderArgsProvider.get()));
+  }
+
   @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
@@ -319,7 +326,7 @@
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
           queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
-        if (!seen.add(cd.getId())) {
+        if (!seen.add(cd.virtualId())) {
           result.add(cd);
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index 046d24c..7144d3e 100644
--- a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -15,18 +15,21 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.index.query.AndPredicate;
 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.CurrentUser;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
+import com.google.gerrit.server.mail.send.ProjectWatch;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
 public class IsWatchedByPredicate extends AndPredicate<ChangeData> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   protected static String describe(CurrentUser user) {
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
@@ -44,20 +47,16 @@
   protected static ImmutableList<Predicate<ChangeData>> filters(ChangeQueryBuilder.Arguments args)
       throws QueryParseException {
     List<Predicate<ChangeData>> r = new ArrayList<>();
-    ChangeQueryBuilder builder = new ChangeQueryBuilder(args);
+    ProjectWatch.WatcherChangeQueryBuilder builder =
+        new ProjectWatch.WatcherChangeQueryBuilder(args);
     for (ProjectWatchKey w : getWatches(args)) {
       Predicate<ChangeData> f = null;
       if (w.filter() != null) {
         try {
           f = builder.parse(w.filter());
-          if (QueryBuilder.find(f, IsWatchedByPredicate.class) != null) {
-            // If the query is going to infinite loop, assume it
-            // will never match and return null. Yes this test
-            // prevents you from having a filter that matches what
-            // another user is filtering on. :-)
-            continue;
-          }
         } catch (QueryParseException e) {
+          logger.atFine().log(
+              "Ignoring non-parseable filter of project watch %s: %s", w, e.getMessage());
           continue;
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
index 420ab61d..fadffd7 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
@@ -150,16 +150,30 @@
     public boolean match(ChangeData cd) {
       Change change = cd.change();
       if (change == null) {
+        logger.atFine().log(
+            "%s doesn't match because the change has disappeared.", magicLabelVote.formatLabel());
         return false; // The change has disappeared.
       }
 
       Optional<ProjectState> project = args.projectCache.get(change.getDest().project());
       if (!project.isPresent()) {
+        logger.atFine().log(
+            "%s doesn't match change %s because its project %s has disappeared.",
+            magicLabelVote.formatLabel(),
+            cd.change().getChangeId(),
+            change.getDest().project().get());
         return false; // The project has disappeared.
       }
 
       LabelType labelType = type(project.get().getLabelTypes(), magicLabelVote.label());
       if (labelType == null) {
+        logger.atFine().log(
+            "%s doesn't match change %s because the label is not defined by its project %s (label"
+                + " types = %s)",
+            magicLabelVote.formatLabel(),
+            cd.change().getChangeId(),
+            project.get(),
+            project.get().getLabelTypes());
         return false; // Label is not defined by this project.
       }
 
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index 9633a18..8894287 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -52,7 +52,7 @@
           Set<Change.Id> have = new HashSet<>();
           for (ResultSet<ChangeData> resultSet : results) {
             for (ChangeData result : resultSet) {
-              if (have.add(result.getId())) {
+              if (have.add(result.virtualId())) {
                 r.add(result);
               }
             }
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index d3b5605..0fd9c0e 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -262,7 +261,6 @@
       Map<Project.NameKey, RevWalk> revWalks,
       AccountAttributeLoader accountLoader)
       throws IOException {
-    LabelTypes labelTypes = d.getLabelTypes();
     ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), accountLoader);
     c.hashtags = Lists.newArrayList(d.hashtags());
     eventFactory.extend(c, d.change());
@@ -286,67 +284,71 @@
       eventFactory.addCommitMessage(c, d.commitMessage());
     }
 
-    RevWalk rw = null;
     if (includePatchSets || includeCurrentPatchSet || includeDependencies) {
       Project.NameKey p = d.change().getProject();
-      rw = revWalks.get(p);
+      Repository repo;
+      RevWalk rw = revWalks.get(p);
       // Cache and reuse repos and revwalks.
       if (rw == null) {
-        Repository repo = repoManager.openRepository(p);
+        repo = repoManager.openRepository(p);
         checkState(repos.put(p, repo) == null);
         rw = new RevWalk(repo);
         revWalks.put(p, rw);
+      } else {
+        repo = repos.get(p);
       }
-    }
 
-    if (includePatchSets) {
-      eventFactory.addPatchSets(
-          rw,
-          c,
-          d.patchSets(),
-          includeApprovals ? d.conditionallyLoadApprovalsWithCopied().asMap() : null,
-          includeFiles,
-          d.change(),
-          labelTypes,
-          accountLoader);
-    }
-
-    if (includeCurrentPatchSet) {
-      PatchSet current = d.currentPatchSet();
-      if (current != null) {
-        c.currentPatchSet = eventFactory.asPatchSetAttribute(rw, d.change(), current);
-        eventFactory.addApprovals(
-            c.currentPatchSet, d.currentApprovals(), labelTypes, accountLoader);
-
-        if (includeFiles) {
-          eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
-        }
+      if (includePatchSets) {
+        eventFactory.addPatchSets(
+            rw,
+            repo.getConfig(),
+            c,
+            includeApprovals ? d.conditionallyLoadApprovalsWithCopied().asMap() : null,
+            includeFiles,
+            d,
+            accountLoader);
         if (includeComments) {
-          eventFactory.addPatchSetComments(c.currentPatchSet, d.publishedComments(), accountLoader);
+          for (PatchSetAttribute attribute : c.patchSets) {
+            eventFactory.addPatchSetComments(attribute, d.publishedComments(), accountLoader);
+          }
         }
       }
+
+      if (includeCurrentPatchSet) {
+        PatchSet current = d.currentPatchSet();
+        if (current != null) {
+          if (includePatchSets) {
+            for (PatchSetAttribute attribute : c.patchSets) {
+              if (attribute.number == current.number()) {
+                c.currentPatchSet = attribute.shallowClone();
+                // approvals will be populated later using different logic than --patch-sets uses
+                c.currentPatchSet.approvals = null;
+                break;
+              }
+            }
+          } else {
+            c.currentPatchSet =
+                eventFactory.asPatchSetAttribute(rw, repo.getConfig(), d, current, accountLoader);
+            if (includeFiles) {
+              eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
+            }
+            if (includeComments) {
+              eventFactory.addPatchSetComments(
+                  c.currentPatchSet, d.publishedComments(), accountLoader);
+            }
+          }
+          eventFactory.addApprovals(
+              c.currentPatchSet, d.currentApprovals(), d.getLabelTypes(), accountLoader);
+        }
+      }
+
+      if (includeDependencies) {
+        eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
+      }
     }
 
     if (includeComments) {
       eventFactory.addComments(c, d.messages(), accountLoader);
-      if (includePatchSets) {
-        eventFactory.addPatchSets(
-            rw,
-            c,
-            d.patchSets(),
-            includeApprovals ? d.approvals().asMap() : null,
-            includeFiles,
-            d.change(),
-            labelTypes,
-            accountLoader);
-        for (PatchSetAttribute attribute : c.patchSets) {
-          eventFactory.addPatchSetComments(attribute, d.publishedComments(), accountLoader);
-        }
-      }
-    }
-
-    if (includeDependencies) {
-      eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
     ImmutableList<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index ebe4390..02d2ca6 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -71,7 +71,7 @@
    *
    * @param args arguments to be parsed
    */
-  PredicateArgs(String args) throws QueryParseException {
+  public PredicateArgs(String args) throws QueryParseException {
     positional = new ArrayList<>();
     keyValue = new HashMap<>();
 
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index cb92ddd..55d3505 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -28,13 +28,16 @@
 import com.google.gerrit.server.submitrequirement.predicate.RegexAuthorEmailPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.RegexCommitterEmailPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.RegexUploaderEmailPredicateFactory;
+import com.google.gerrit.server.submitrequirement.predicate.SubmitRequirementLabelExtensionPredicate;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.List;
 import java.util.Locale;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
  * A query builder for submit requirement expressions that includes all {@link ChangeQueryBuilder}
@@ -48,6 +51,8 @@
       new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
 
   private final DistinctVotersPredicate.Factory distinctVotersPredicateFactory;
+  private final SubmitRequirementLabelExtensionPredicate.Factory
+      submitRequirementLabelExtensionPredicateFactory;
   private final HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory;
 
   /**
@@ -70,11 +75,15 @@
   SubmitRequirementChangeQueryBuilder(
       Arguments args,
       DistinctVotersPredicate.Factory distinctVotersPredicateFactory,
+      SubmitRequirementLabelExtensionPredicate.Factory
+          submitRequirementLabelExtensionPredicateFactory,
       FileEditsPredicate.Factory fileEditsPredicateFactory,
       HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory,
       RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory) {
     super(def, args);
     this.distinctVotersPredicateFactory = distinctVotersPredicateFactory;
+    this.submitRequirementLabelExtensionPredicateFactory =
+        submitRequirementLabelExtensionPredicateFactory;
     this.fileEditsPredicateFactory = fileEditsPredicateFactory;
     this.hasSubmoduleUpdateFactory = hasSubmoduleUpdateFactory;
     this.regexUploaderEmailPredicateFactory = regexUploaderEmailPredicateFactory;
@@ -150,6 +159,16 @@
     return distinctVotersPredicateFactory.create(value);
   }
 
+  @Override
+  public Predicate<ChangeData> label(String value)
+      throws QueryParseException, IOException, ConfigInvalidException {
+    if (SubmitRequirementLabelExtensionPredicate.matches(value)) {
+      return submitRequirementLabelExtensionPredicateFactory.create(value);
+    }
+    SubmitRequirementLabelExtensionPredicate.validateIfNoMatch(value);
+    return super.label(value);
+  }
+
   /**
    * A SR operator that can match with file path and content pattern. The value should be of the
    * form:
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index ade6606..c70f00f 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -23,6 +23,8 @@
  * Provides methods required for parsing projects queries.
  *
  * <p>Internally (at google), this interface has a different implementation, comparing to upstream.
+ * For example, Google disables the `state` predicate which can expose it by setting
+ * `gerrit.projectStatePredicateEnabled = false`.
  */
 public interface ProjectQueryBuilder {
   String FIELD_LIMIT = "limit";
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index 73991c9..359393e 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi;
 
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.restapi.access.AccessRestApiModule;
 import com.google.gerrit.server.restapi.account.AccountRestApiModule;
@@ -42,5 +43,6 @@
     install(new PluginRestApiModule());
     install(new ProjectRestApiModule());
     install(new ProjectRestApiModule.BatchModule());
+    install(new ChangeCleanupRunnerModule());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index a09e1bc..adf4bdd 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -90,6 +90,7 @@
     put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
     delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
 
+    get(ACCOUNT_KIND, "state").to(GetState.class);
     get(ACCOUNT_KIND, "status").to(GetStatus.class);
     put(ACCOUNT_KIND, "status").to(PutStatus.class);
     get(ACCOUNT_KIND, "username").to(GetUsername.class);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
index a49952c..79038af 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.restapi.change.CommentJson;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
@@ -64,6 +65,7 @@
 @Singleton
 public class DeleteDraftCommentsUtil {
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeData.Factory changeDataFactory;
@@ -77,6 +79,7 @@
   @Inject
   public DeleteDraftCommentsUtil(
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       Provider<ChangeQueryBuilder> queryBuilderProvider,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
@@ -86,6 +89,7 @@
       DraftCommentsReader draftCommentsReader,
       PatchSetUtil psUtil) {
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
@@ -122,7 +126,7 @@
       // were,
       // all updates from this operation only happen in All-Users and thus are fully atomic, so
       // allowing partial failure would have little value.
-      BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
+      batchUpdates.execute(updates.values(), ImmutableList.of(), false);
     }
     return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
   }
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index c45694e..2f871c1 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.globalPermissionName;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.pluginCapabilityName;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.PermissionRange;
@@ -47,6 +48,7 @@
 import java.util.LinkedHashMap;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import org.kohsuke.args4j.Option;
 
@@ -144,7 +146,8 @@
     }
   }
 
-  private static class Range {
+  @VisibleForTesting
+  public static class Range {
     private transient PermissionRange range;
 
     @SuppressWarnings("unused")
@@ -153,7 +156,8 @@
     @SuppressWarnings("unused")
     private int max;
 
-    Range(PermissionRange r) {
+    @VisibleForTesting
+    public Range(PermissionRange r) {
       range = r;
       min = r.getMin();
       max = r.getMax();
@@ -163,6 +167,20 @@
     public String toString() {
       return range.toString();
     }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(min, max);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Range) {
+        Range range = (Range) o;
+        return min == range.min && max == range.max;
+      }
+      return false;
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/account/GetState.java b/java/com/google/gerrit/server/restapi/account/GetState.java
new file mode 100644
index 0000000..802ecde
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetState.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.common.AccountStateInfo;
+import com.google.gerrit.extensions.common.MetadataInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountStateProvider;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+
+/**
+ * REST endpoint to retrieve the superset of all information related to an account. This information
+ * is useful to inspect issues with the account and its permissions.
+ *
+ * <p>Users can only get the own account state. Getting the account state of other users is not
+ * allowed.
+ */
+@Singleton
+public class GetState implements RestReadView<AccountResource> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> self;
+  private final Provider<GetCapabilities> getCapabilities;
+  private final GetDetail getDetail;
+  private final GetGroups getGroups;
+  private final GetExternalIds getExternalIds;
+  private final PluginSetContext<AccountStateProvider> accountStateProviders;
+
+  @Inject
+  GetState(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> self,
+      Provider<GetCapabilities> getCapabilities,
+      GetDetail getDetail,
+      GetGroups getGroups,
+      GetExternalIds getExternalIds,
+      PluginSetContext<AccountStateProvider> accountStateProviders) {
+    this.permissionBackend = permissionBackend;
+    this.self = self;
+    this.getCapabilities = getCapabilities;
+    this.getDetail = getDetail;
+    this.getGroups = getGroups;
+    this.getExternalIds = getExternalIds;
+    this.accountStateProviders = accountStateProviders;
+  }
+
+  @Override
+  public Response<AccountStateInfo> apply(AccountResource rsrc)
+      throws RestApiException, PermissionBackendException, IOException {
+    if (!self.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (!rsrc.getUser().hasSameAccountId(self.get())) {
+      try {
+        permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+      } catch (AuthException e) {
+        throw new AuthException(
+            String.format("cannot get account state of other user: %s", e.getMessage()));
+      }
+    }
+
+    AccountStateInfo accountState = new AccountStateInfo();
+    accountState.account = getDetail.apply(rsrc).value();
+
+    if (permissionBackend.usesDefaultCapabilities()) {
+      accountState.capabilities = getCapabilities.get().apply(rsrc).value();
+    }
+
+    accountState.groups = getGroups.apply(rsrc).value();
+    accountState.externalIds = getExternalIds.apply(rsrc).value();
+    accountState.metadata = getMetadata(rsrc.getUser().getAccountId());
+    return Response.ok(accountState);
+  }
+
+  private ImmutableList<MetadataInfo> getMetadata(Account.Id accountId) {
+    ArrayList<MetadataInfo> metadataList = new ArrayList<>();
+    accountStateProviders.runEach(
+        accountStateProvider -> metadataList.addAll(accountStateProvider.getMetadata(accountId)));
+    return metadataList.stream()
+        .sorted(
+            Comparator.comparing((MetadataInfo metadata) -> metadata.name)
+                .thenComparing((MetadataInfo metadata) -> metadata.value))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index b1af85e..1c7b1ca 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -63,7 +62,6 @@
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
-  private final ExternalIds externalIds;
   private final ExternalIdFactory externalIdFactory;
 
   @Inject
@@ -71,12 +69,10 @@
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      ExternalIds externalIds,
       ExternalIdFactory externalIdFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.accountsUpdateProvider = accountsUpdateProvider;
-    this.externalIds = externalIds;
     this.externalIdFactory = externalIdFactory;
   }
 
@@ -99,7 +95,7 @@
             .update(
                 "Set Preferred Email via API",
                 user.getAccountId(),
-                (a, u) -> {
+                (r, a, u) -> {
                   if (preferredEmail.equals(a.account().preferredEmail())) {
                     alreadyPreferred.set(true);
                   } else {
@@ -125,7 +121,7 @@
                       if (user.hasEmailAddress(preferredEmail)) {
                         // but Realm says the user is allowed to use this email
                         ImmutableSet<ExternalId> existingExtIdsWithThisEmail =
-                            externalIds.byEmail(preferredEmail);
+                            r.externalIdsReader().byEmail(preferredEmail);
                         if (!existingExtIdsWithThisEmail.isEmpty()) {
                           // but the email is already assigned to another account
                           logger.atWarning().log(
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 1565aba..95aa07c 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -69,7 +69,7 @@
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    if (starredChangesReader.isStarred(user.getAccountId(), change.getId())) {
+    if (starredChangesReader.isStarred(user.getAccountId(), change.getVirtualId())) {
       return new AccountResource.StarredChange(user, change);
     }
     throw new ResourceNotFoundException(id);
@@ -125,7 +125,7 @@
       }
 
       try {
-        starredChangesWriter.star(self.get().getAccountId(), change.getId());
+        starredChangesWriter.star(self.get().getAccountId(), change.getVirtualId());
       } catch (DuplicateKeyException e) {
         return Response.none();
       }
@@ -168,7 +168,7 @@
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
       }
-      starredChangesWriter.unstar(self.get().getAccountId(), rsrc.getChange().getId());
+      starredChangesWriter.unstar(self.get().getAccountId(), rsrc.getVirtualId());
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 7d8c793..6b7f563 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -16,117 +16,89 @@
 
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.time.Instant;
-import java.time.ZoneId;
 import java.util.List;
-import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class ApplyPatch implements RestModifyView<ChangeResource, ApplyPatchPatchSetInput> {
-  private final ChangeJson.Factory jsonFactory;
+
   private final ContributorAgreementsChecker contributorAgreements;
-  private final Provider<IdentifiedUser> user;
   private final GitRepositoryManager gitManager;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final PatchSetInserter.Factory patchSetInserterFactory;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final ZoneId serverZoneId;
   private final ProjectCache projectCache;
   private final ChangeUtil changeUtil;
+  private final PatchSetCreator patchSetCreator;
 
   @Inject
   ApplyPatch(
-      ChangeJson.Factory jsonFactory,
       ContributorAgreementsChecker contributorAgreements,
-      Provider<IdentifiedUser> user,
       GitRepositoryManager gitManager,
-      BatchUpdate.Factory batchUpdateFactory,
-      PatchSetInserter.Factory patchSetInserterFactory,
       Provider<InternalChangeQuery> queryProvider,
-      @GerritPersonIdent PersonIdent myIdent,
       ProjectCache projectCache,
-      ChangeUtil changeUtil) {
-    this.jsonFactory = jsonFactory;
+      ChangeUtil changeUtil,
+      PatchSetCreator patchSetCreator) {
     this.contributorAgreements = contributorAgreements;
-    this.user = user;
     this.gitManager = gitManager;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.patchSetInserterFactory = patchSetInserterFactory;
     this.queryProvider = queryProvider;
-    this.serverZoneId = myIdent.getZoneId();
     this.projectCache = projectCache;
     this.changeUtil = changeUtil;
+    this.patchSetCreator = patchSetCreator;
   }
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, ApplyPatchPatchSetInput input)
       throws IOException, UpdateException, RestApiException, PermissionBackendException,
           ConfigInvalidException, NoSuchProjectException, InvalidChangeOperationException {
-    NameKey project = rsrc.getProject();
-    contributorAgreements.check(project, rsrc.getUser());
-    BranchNameKey destBranch = rsrc.getChange().getDest();
-
     if (input == null || input.patch == null || input.patch.patch == null) {
       throw new BadRequestException("patch required");
     }
 
+    NameKey project = rsrc.getProject();
+    contributorAgreements.check(project, rsrc.getUser());
+    BranchNameKey destBranch = rsrc.getChange().getDest();
+
     try (Repository repo = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
         // created later on, to ensure the applied commit is flushed
@@ -140,22 +112,7 @@
             String.format("Branch %s does not exist.", destBranch.branch()));
       }
       ChangeData destChange = rsrc.getChangeData();
-      if (destChange == null) {
-        throw new PreconditionFailedException(
-            "patch:apply cannot be called without a destination change.");
-      }
-
-      if (destChange.change().isClosed()) {
-        throw new PreconditionFailedException(
-            String.format(
-                "patch:apply with Change-Id %s could not update the existing change %d "
-                    + "in destination branch %s of project %s, because the change was closed (%s)",
-                destChange.getId(),
-                destChange.getId().get(),
-                destBranch.branch(),
-                destBranch.project(),
-                destChange.change().getStatus().name()));
-      }
+      patchSetCreator.validateChangeCanBeAppended(destChange, destBranch);
 
       if (!Strings.isNullOrEmpty(input.base) && Boolean.TRUE.equals(input.amend)) {
         throw new BadRequestException("amend only works with existing revisions. omit base.");
@@ -173,7 +130,8 @@
         if (latestPatchset.getParentCount() != 1) {
           throw new BadRequestException(
               String.format(
-                  "Cannot parse base commit for a change with none or multiple parents. Change ID: %s.",
+                  "Cannot parse base commit for a change with none or multiple parents. Change ID:"
+                      + " %s.",
                   destChange.getId()));
         }
         if (Boolean.TRUE.equals(input.amend)) {
@@ -184,50 +142,41 @@
           parents = ImmutableList.of(baseCommit);
         }
       }
+
+      List<ListChangesOption> opts = input.responseFormatOptions;
+      if (opts == null) {
+        opts = ImmutableList.of();
+      }
+
       PatchApplier.Result applyResult =
           ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
-      ObjectId treeId = applyResult.getTreeId();
 
-      Instant now = TimeUtil.now();
-      PersonIdent committerIdent =
-          Optional.ofNullable(latestPatchset.getCommitterIdent())
-              .map(
-                  ident ->
-                      user.get()
-                          .newCommitterIdent(ident.getEmailAddress(), now, serverZoneId)
-                          .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId)))
-              .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId));
-      PersonIdent authorIdent =
-          input.author == null
-              ? committerIdent
-              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
       String commitMessage =
           buildFullCommitMessage(
               project,
               latestPatchset,
               input,
-              ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
+              ApplyPatchUtil.getResultPatch(
+                  repo, reader, baseCommit, revWalk.lookupTree(applyResult.getTreeId())),
               applyResult.getErrors());
 
-      ObjectId appliedCommit =
-          CommitUtil.createCommitWithTree(
-              oi, authorIdent, committerIdent, parents, commitMessage, treeId);
-      CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
-      oi.flush();
-
-      Change resultChange;
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
-        bu.setRepository(repo, revWalk, oi);
-        resultChange =
-            insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
-      } catch (NoSuchChangeException | RepositoryNotFoundException e) {
-        throw new ResourceConflictException(e.getMessage());
+      ChangeInfo changeInfo =
+          patchSetCreator.createPatchSetWithSuppliedTree(
+              project,
+              destChange,
+              latestPatchset,
+              parents,
+              input.author,
+              opts,
+              repo,
+              oi,
+              revWalk,
+              applyResult.getTreeId(),
+              commitMessage);
+      if (changeInfo.containsGitConflicts == null
+          && applyResult.getErrors().stream().anyMatch(Error::isGitConflict)) {
+        changeInfo.containsGitConflicts = true;
       }
-      List<ListChangesOption> opts = input.responseFormatOptions;
-      if (opts == null) {
-        opts = ImmutableList.of();
-      }
-      ChangeInfo changeInfo = jsonFactory.create(opts).format(resultChange);
       return Response.ok(changeInfo);
     }
   }
@@ -262,7 +211,7 @@
     }
     String commitMessage =
         ApplyPatchUtil.buildCommitMessage(
-            messageWithNoFooters, footerLines, input.patch.patch, resultPatch, errors);
+            messageWithNoFooters, footerLines, input.patch, resultPatch, errors);
 
     boolean changeIdRequired =
         projectCache
@@ -275,28 +224,6 @@
     return commitMessage;
   }
 
-  private static Change insertPatchSet(
-      BatchUpdate bu,
-      Repository git,
-      PatchSetInserter.Factory patchSetInserterFactory,
-      ChangeNotes destNotes,
-      CodeReviewCommit commit)
-      throws IOException, UpdateException, RestApiException {
-    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-      Change destChange = destNotes.getChange();
-      PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
-      PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
-      inserter.setMessage(buildMessageForPatchSet(psId));
-      bu.addOp(destChange.getId(), inserter);
-      bu.execute();
-      return inserter.getChange();
-    }
-  }
-
-  private static String buildMessageForPatchSet(PatchSet.Id psId) {
-    return new StringBuilder(String.format("Uploaded patch set %s.", psId.get())).toString();
-  }
-
   private String removeFooters(String originalMessage, List<FooterLine> footerLines) {
     if (footerLines.isEmpty()) {
       return originalMessage;
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
index a55ef84b..4e05420 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
@@ -16,10 +16,12 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Comment.Range;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -27,15 +29,19 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.CommitModification;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.fixes.FixReplacementInterpreter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
+import com.google.gerrit.server.patch.MagicFile;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -45,7 +51,14 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
 
 /** Applies a fix that is provided as part of the request body. */
 @Singleton
@@ -74,7 +87,7 @@
   public Response<EditInfo> apply(
       RevisionResource revisionResource, ApplyProvidedFixInput applyProvidedFixInput)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
-          ResourceNotFoundException, PermissionBackendException {
+          ResourceNotFoundException, PermissionBackendException, RestApiException {
     if (applyProvidedFixInput == null) {
       throw new BadRequestException("applyProvidedFixInput is required");
     }
@@ -83,9 +96,19 @@
     }
     Project.NameKey project = revisionResource.getProject();
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    PatchSet patchSet = revisionResource.getPatchSet();
+    PatchSet targetPatchSet = revisionResource.getPatchSet();
 
     ChangeNotes changeNotes = revisionResource.getNotes();
+    PatchSet originPatchSetForFix =
+        applyProvidedFixInput.originalPatchsetForFix != null
+                && applyProvidedFixInput.originalPatchsetForFix > 0
+            ? changeNotes
+                .getPatchSets()
+                .get(
+                    PatchSet.id(
+                        revisionResource.getChange().getId(),
+                        applyProvidedFixInput.originalPatchsetForFix))
+            : targetPatchSet;
 
     List<FixReplacement> fixReplacements =
         applyProvidedFixInput.fixReplacementInfos.stream()
@@ -94,15 +117,87 @@
 
     try (Repository repository = gitRepositoryManager.openRepository(project)) {
       CommitModification commitModification =
-          fixReplacementInterpreter.toCommitModification(
-              repository, projectState, patchSet.commitId(), fixReplacements);
+          getCommitModification(
+              repository, projectState, originPatchSetForFix, targetPatchSet, fixReplacements);
       ChangeEdit changeEdit =
           changeEditModifier.combineWithModifiedPatchSetTree(
-              repository, changeNotes, patchSet, commitModification);
+              repository, changeNotes, targetPatchSet, commitModification);
 
       return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
     } catch (InvalidChangeOperationException e) {
       throw new ResourceConflictException(e.getMessage());
     }
   }
+
+  /**
+   * Returns CommitModification for fixes and rebase it if the fix is for an older patchset.
+   *
+   * <p>The method creates CommitModification by applying {@code fixReplacements} to the {@code
+   * basePatchSetForFix}. If the {@code targetPatchSetForFix} is different from the {@code
+   * basePatchSetForFix}, CommitModification is created from the {@link PatchApplier.Result}, after
+   * applying the patch generated from {@code basePatchSetForFix} to the {@code
+   * targetPatchSetForFix}.
+   *
+   * <p>Note: if there is a fix for a commit message and commit messages are different in {@code
+   * basePatchSetForFix} and {@code targetPatchSetForFix}, the method can't move the fix to the
+   * {@code targetPatchSetForFix} and throws {@link ResourceConflictException}. This limitations
+   * exists because the method uses ApplyPatchUtil which operates only on files.
+   */
+  private CommitModification getCommitModification(
+      Repository repository,
+      ProjectState projectState,
+      PatchSet basePatchSetForFix,
+      PatchSet targetPatchSetForFix,
+      List<FixReplacement> fixReplacements)
+      throws IOException, InvalidChangeOperationException, RestApiException {
+    CommitModification originCommitModification =
+        fixReplacementInterpreter.toCommitModification(
+            repository, projectState, basePatchSetForFix.commitId(), fixReplacements);
+    if (basePatchSetForFix.id().equals(targetPatchSetForFix.id())) {
+      return originCommitModification;
+    }
+    RevCommit originCommit = repository.parseCommit(basePatchSetForFix.commitId());
+    ObjectId newTreeId =
+        ChangeEditModifier.createNewTree(
+            repository, originCommit, originCommitModification.treeModifications());
+    CommitModification.Builder resultBuilder = CommitModification.builder();
+    String patch;
+    try (RevWalk rw = new RevWalk(repository)) {
+      ObjectId targetCommit = targetPatchSetForFix.commitId();
+      if (originCommitModification.newCommitMessage().isPresent()) {
+        MagicFile originCommitMessageFile =
+            MagicFile.forCommitMessage(rw.getObjectReader(), originCommit);
+        String originCommitMessage = originCommitMessageFile.modifiableContent();
+        MagicFile targetCommitMessageFile =
+            MagicFile.forCommitMessage(rw.getObjectReader(), targetCommit);
+        String targetCommitMessage = targetCommitMessageFile.modifiableContent();
+        if (!originCommitMessage.equals(targetCommitMessage)) {
+          throw new ResourceConflictException(
+              "The fix attempts to modify commit message of an older patchset, but commit message has been updated in a newer patchset. The fix can't be applied.");
+        }
+        resultBuilder.newCommitMessage(originCommitModification.newCommitMessage().get());
+      }
+
+      patch =
+          ApplyPatchUtil.getResultPatch(
+              repository, repository.newObjectReader(), originCommit, rw.lookupTree(newTreeId));
+      PatchApplier.Result result;
+      try (ObjectInserter oi = repository.newObjectInserter()) {
+        ApplyPatchInput inp = new ApplyPatchInput();
+        inp.patch = patch;
+        inp.allowConflicts = false;
+        result =
+            ApplyPatchUtil.applyPatch(repository, oi, inp, repository.parseCommit(targetCommit));
+        oi.flush();
+        for (String path : result.getPaths()) {
+          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, result.getTreeId())) {
+            ObjectLoader loader = rw.getObjectReader().open(tw.getObjectId(0));
+            resultBuilder.addTreeModification(
+                new ChangeFileContentModification(path, RawInputUtil.create(loader.getBytes())));
+          }
+        }
+      }
+    }
+    return resultBuilder.build();
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 63edb7b..729933e 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.change.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.change.ValidationOptionsUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.CommitUtil;
@@ -78,6 +79,7 @@
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -116,11 +118,13 @@
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final boolean useDiff3;
 
   @Inject
   CherryPickChange(
       Sequences seq,
       Provider<InternalChangeQuery> queryProvider,
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent myIdent,
       GitRepositoryManager gitManager,
       Provider<IdentifiedUser> user,
@@ -147,6 +151,7 @@
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.useDiff3 = cfg.getBoolean("change", null, "diff3ConflictView", false);
   }
 
   /**
@@ -376,7 +381,8 @@
                 revWalk,
                 input.parent - 1,
                 input.allowEmpty,
-                input.allowConflicts);
+                input.allowConflicts,
+                useDiff3);
         logger.atFine().log("flushing inserter %s", oi);
         oi.flush();
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
@@ -406,6 +412,7 @@
                     destChange.notes(),
                     cherryPickCommit,
                     sourceChange,
+                    sourceCommit,
                     newTopic,
                     input,
                     workInProgress);
@@ -439,6 +446,7 @@
       ChangeNotes destNotes,
       CodeReviewCommit cherryPickCommit,
       @Nullable Change sourceChange,
+      @Nullable ObjectId sourceCommit,
       String topic,
       CherryPickInput input,
       @Nullable Boolean workInProgress)
@@ -446,13 +454,20 @@
     Change destChange = destNotes.getChange();
     PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
     PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
-    inserter.setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".");
+    BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    inserter.setMessage(
+        messageForDestinationChange(
+            inserter.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit));
     inserter.setTopic(topic);
     if (workInProgress != null) {
       inserter.setWorkInProgress(workInProgress);
-    }
-    if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
-      inserter.setWorkInProgress(false);
+    } else {
+      boolean shouldSetToWIP =
+          (sourceChange != null && sourceChange.isWorkInProgress())
+              || !cherryPickCommit.getFilesWithGitConflicts().isEmpty();
+      if (shouldSetToWIP != destNotes.getChange().isWorkInProgress()) {
+        inserter.setWorkInProgress(shouldSetToWIP);
+      }
     }
     inserter.setValidationOptions(
         ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
@@ -469,20 +484,6 @@
     return destChange.getId();
   }
 
-  /**
-   * We should set the change to be "ready for review" if: 1. workInProgress is not already set on
-   * this request. 2. The patch-set doesn't have any git conflict markers. 3. The change used to be
-   * work in progress (because of a previous patch-set).
-   */
-  private boolean shouldSetToReady(
-      CodeReviewCommit cherryPickCommit,
-      ChangeNotes destChangeNotes,
-      @Nullable Boolean workInProgress) {
-    return workInProgress == null
-        && cherryPickCommit.getFilesWithGitConflicts().isEmpty()
-        && destChangeNotes.getChange().isWorkInProgress();
-  }
-
   private Change.Id createNewChange(
       BatchUpdate bu,
       CodeReviewCommit cherryPickCommit,
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 341a9d9..4c3c7b0 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -26,12 +26,14 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.converter.ChangeInputProtoConverter;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -52,6 +54,7 @@
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -71,6 +74,7 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -96,6 +100,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -110,6 +115,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TreeFormatter;
 import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
@@ -118,6 +124,8 @@
 public class CreateChange
     implements RestCollectionModifyView<TopLevelResource, ChangeResource, ChangeInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ChangeInputProtoConverter CHANGE_INPUT_PROTO_CONVERTER =
+      ChangeInputProtoConverter.INSTANCE;
 
   private final BatchUpdate.Factory updateFactory;
   private final String anonymousCowardName;
@@ -191,11 +199,52 @@
     return execute(updateFactory, input, projectsCollection.parse(input.project));
   }
 
-  /** Creates the changes in the given project. This is public for reuse in the project API. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  @FunctionalInterface
+  public interface CommitTreeSupplier {
+    @NonNull
+    ObjectId get(
+        Repository repo, ObjectInserter oi, ObjectReader or, ChangeInput input, RevCommit mergeTip)
+        throws IOException, RestApiException;
+  }
+
+  /**
+   * Creates the changes in the given project, using the proto representation of ChangeInput -
+   * {@link com.google.gerrit.proto.Entities.ChangeInput}.
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory,
+      Entities.ChangeInput input,
+      CommitTreeSupplier commitTreeSupplier)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
+    return execute(
+        updateFactory,
+        CHANGE_INPUT_PROTO_CONVERTER.fromProto(input),
+        projectsCollection.parse(input.getProject()),
+        Optional.of(commitTreeSupplier));
+  }
+
+  /**
+   * Creates the changes in the given project, using the java-class representation of ChangeInput -
+   * {@link com.google.gerrit.extensions.common.ChangeInput}. This is public for reuse in the
+   * project API.
+   */
   public Response<ChangeInfo> execute(
       BatchUpdate.Factory updateFactory, ChangeInput input, ProjectResource projectResource)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
+    return execute(updateFactory, input, projectResource, Optional.empty());
+  }
+
+  private Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory,
+      ChangeInput input,
+      ProjectResource projectResource,
+      Optional<CommitTreeSupplier> commitTreeSupplier)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
     if (!user.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
@@ -204,14 +253,15 @@
     projectState.checkStatePermitsWrite();
 
     IdentifiedUser me = user.get().asIdentifiedUser();
-    checkAndSanitizeChangeInput(input, me);
+    checkAndSanitizeChangeInput(input, me, commitTreeSupplier);
 
     Project.NameKey project = projectResource.getNameKey();
     contributorAgreements.check(project, user.get());
 
     checkRequiredPermissions(project, input.branch, input.author);
 
-    ChangeInfo newChange = createNewChange(input, me, projectState, updateFactory);
+    ChangeInfo newChange =
+        createNewChange(input, me, projectState, updateFactory, commitTreeSupplier);
     return Response.created(newChange);
   }
 
@@ -225,7 +275,8 @@
    * @param me the user who sent the current request to create a change.
    * @throws BadRequestException if the input is not legal.
    */
-  private void checkAndSanitizeChangeInput(ChangeInput input, IdentifiedUser me)
+  private void checkAndSanitizeChangeInput(
+      ChangeInput input, IdentifiedUser me, Optional<CommitTreeSupplier> commitTreeSupplier)
       throws RestApiException, PermissionBackendException, IOException {
     if (Strings.isNullOrEmpty(input.branch)) {
       throw new BadRequestException("branch must be non-empty");
@@ -303,6 +354,11 @@
       throw new BadRequestException("Only one of `merge` and `patch` arguments can be set.");
     }
 
+    if ((input.merge != null || input.patch != null) && commitTreeSupplier.isPresent()) {
+      throw new BadRequestException(
+          "`CommitTreeSupplier` cannot be provided along with `merge` or `patch` arguments");
+    }
+
     if (input.author != null
         && (Strings.isNullOrEmpty(input.author.email)
             || Strings.isNullOrEmpty(input.author.name))) {
@@ -332,7 +388,8 @@
       ChangeInput input,
       IdentifiedUser me,
       ProjectState projectState,
-      BatchUpdate.Factory updateFactory)
+      BatchUpdate.Factory updateFactory,
+      Optional<CommitTreeSupplier> commitTreeSupplier)
       throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
           UpdateException {
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
@@ -384,6 +441,7 @@
         String commitMessage = getCommitMessage(input.subject, me);
 
         CodeReviewCommit c;
+        boolean hasGitConflicts = false;
         if (input.merge != null) {
           // create a merge commit
           c =
@@ -416,7 +474,7 @@
                   ApplyPatchUtil.buildCommitMessage(
                       input.subject,
                       ImmutableList.of(),
-                      input.patch.patch,
+                      input.patch,
                       ApplyPatchUtil.getResultPatch(git, reader, mergeTip, rw.lookupTree(treeId)),
                       applyResult.getErrors()),
                   me);
@@ -429,6 +487,21 @@
                       ImmutableList.of(mergeTip),
                       appliedPatchCommitMessage,
                       treeId));
+          hasGitConflicts = applyResult.getErrors().stream().anyMatch(Error::isGitConflict);
+        } else if (commitTreeSupplier.isPresent()) {
+          c =
+              createCommitWithSuppliedTree(
+                  git,
+                  oi,
+                  reader,
+                  rw,
+                  mergeTip,
+                  input,
+                  commitTreeSupplier.get(),
+                  author,
+                  committer,
+                  commitMessage);
+
         } else {
           // create an empty commit.
           c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
@@ -477,7 +550,8 @@
           opts = ImmutableList.of();
         }
         ChangeInfo changeInfo = jsonFactory.create(opts).format(ins.getChange());
-        changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
+        changeInfo.containsGitConflicts =
+            (!c.getFilesWithGitConflicts().isEmpty() || hasGitConflicts) ? true : null;
         return changeInfo;
       } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
         throw new BadRequestException(e.getMessage());
@@ -615,6 +689,27 @@
             oi, authorIdent, committerIdent, parents, commitMessage, treeId));
   }
 
+  private static CodeReviewCommit createCommitWithSuppliedTree(
+      Repository repo,
+      ObjectInserter oi,
+      ObjectReader or,
+      CodeReviewRevWalk rw,
+      RevCommit mergeTip,
+      ChangeInput input,
+      CommitTreeSupplier commitTreeSupplier,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      String commitMessage)
+      throws IOException, RestApiException {
+    if (mergeTip == null) {
+      throw new BadRequestException("`CommitTreeSupplier` cannot be used on top of an empty tree.");
+    }
+    ObjectId treeId = commitTreeSupplier.get(repo, oi, or, input, mergeTip);
+    return rw.parseCommit(
+        CommitUtil.createCommitWithTree(
+            oi, authorIdent, committerIdent, ImmutableList.of(mergeTip), commitMessage, treeId));
+  }
+
   private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
     return inserter.insert(new TreeFormatter());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index b3d7fa2..4c6ee9a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -79,6 +80,9 @@
     ReviewerResource r = rsrc.getReviewer();
     Change change = r.getChange();
 
+    if (change.isMerged()) {
+      throw new ResourceConflictException("cannot remove votes from merged change");
+    }
     if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
       throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 96e5645..07e4372 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -17,8 +17,6 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -31,10 +29,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -47,7 +45,6 @@
 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;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -100,7 +97,7 @@
     return new FileResource(rev, id.get());
   }
 
-  public static final class ListFiles implements ETagView<RevisionResource> {
+  public static final class ListFiles implements RestReadView<RevisionResource> {
     private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
     @Option(name = "--base", metaVar = "revision-id")
@@ -353,16 +350,6 @@
       return this;
     }
 
-    @Override
-    public String getETag(RevisionResource resource) {
-      Hasher h = Hashing.murmur3_128().newHasher();
-      resource.prepareETag(h, resource.getUser());
-      // File list comes from the PatchListCache, so any change to the key or value should
-      // invalidate ETag.
-      h.putLong(PatchListKey.serialVersionUID);
-      return h.hash().toString();
-    }
-
     @Nullable
     private ObjectId getOldId(Map<String, FileDiffOutput> fileDiffList) {
       return fileDiffList.isEmpty()
diff --git a/java/com/google/gerrit/server/restapi/change/Fixes.java b/java/com/google/gerrit/server/restapi/change/Fixes.java
index 080c1e7..1d0b8df 100644
--- a/java/com/google/gerrit/server/restapi/change/Fixes.java
+++ b/java/com/google/gerrit/server/restapi/change/Fixes.java
@@ -60,6 +60,9 @@
     allComments.addAll(
         commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().id()));
     for (Comment comment : allComments) {
+      if (comment.fixSuggestions == null) {
+        continue;
+      }
       for (FixSuggestion fixSuggestion : comment.fixSuggestions) {
         if (Objects.equals(fixId, fixSuggestion.fixId)) {
           return new FixResource(revisionResource, fixSuggestion.replacements);
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index d126d8a..c1a8f45 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -37,6 +37,9 @@
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.MissingMetaObjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -45,6 +48,7 @@
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -104,9 +108,14 @@
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
     try {
-      Change change = rsrc.getChange();
-      ObjectId changeMetaRevId = getMetaRevId(change);
-      return Response.withMustRevalidate(newChangeJson().format(change, changeMetaRevId));
+      Optional<ObjectId> changeMetaRevId = getMetaRevId(rsrc.getChange());
+      ChangeInfo changeInfo;
+      if (changeMetaRevId.isPresent()) {
+        changeInfo = newChangeJson().format(rsrc.getChange(), changeMetaRevId.get());
+      } else {
+        changeInfo = newChangeJson().format(rsrc.getChangeData());
+      }
+      return Response.withMustRevalidate(changeInfo);
     } catch (MissingMetaObjectException e) {
       throw new PreconditionFailedException(e.getMessage());
     }
@@ -116,22 +125,25 @@
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
-  @Nullable
-  private ObjectId getMetaRevId(Change change) throws RestApiException {
+  private Optional<ObjectId> getMetaRevId(Change change) throws RestApiException {
     if (metaRevId.isEmpty()) {
-      return null;
+      return Optional.empty();
     }
 
-    // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
-    // without having to fetch the entire /meta ref. If we do so, we have to be careful that
-    // the error messages can't be abused to fetch hidden data.
-    ObjectId metaRevObjectId;
-    try {
-      metaRevObjectId = ObjectId.fromString(metaRevId);
-    } catch (InvalidObjectIdException e) {
-      throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get meta rev ID", Metadata.builder().changeId(change.getId().get()).build())) {
+      // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
+      // without having to fetch the entire /meta ref. If we do so, we have to be careful that
+      // the error messages can't be abused to fetch hidden data.
+      ObjectId metaRevObjectId;
+      try {
+        metaRevObjectId = ObjectId.fromString(metaRevId);
+      } catch (InvalidObjectIdException e) {
+        throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+      }
+      return verifyMetaId(change, metaRevObjectId);
     }
-    return verifyMetaId(change, metaRevObjectId);
   }
 
   private ChangeJson newChangeJson() {
@@ -144,10 +156,10 @@
         cds, this, Streams.stream(pdiFactories.entries()));
   }
 
-  @Nullable
-  private ObjectId verifyMetaId(Change change, @Nullable ObjectId id) throws RestApiException {
+  private Optional<ObjectId> verifyMetaId(Change change, @Nullable ObjectId id)
+      throws RestApiException {
     if (id == null) {
-      return null;
+      return Optional.empty();
     }
 
     String changeMetaRefName = RefNames.changeMetaRef(change.getId());
@@ -159,7 +171,7 @@
       rw.markStart(tip);
       for (RevCommit rev : rw) {
         if (id.equals(rev)) {
-          return id;
+          return Optional.of(id);
         }
       }
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetMessage.java b/java/com/google/gerrit/server/restapi/change/GetMessage.java
index 5715caa..bd66f1f 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMessage.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static java.util.stream.Collectors.toMap;
-
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.common.CommitMessageInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -26,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import org.eclipse.jgit.revwalk.FooterLine;
+import java.util.HashMap;
 
 @Singleton
 public class GetMessage implements RestReadView<ChangeResource> {
@@ -45,9 +44,20 @@
 
     ChangeData cd = changeDataFactory.create(resource.getNotes());
     commitMessageInfo.fullMessage = cd.commitMessage();
-    commitMessageInfo.footers =
-        cd.commitFooters().stream().collect(toMap(FooterLine::getKey, FooterLine::getValue));
-
+    commitMessageInfo.footers = getFooters(cd);
     return Response.ok(commitMessageInfo);
   }
+
+  /**
+   * Gets the footers of the change.
+   *
+   * <p>If there are multiple footers with the same key, only the last footer with that key is
+   * returned.
+   */
+  private ImmutableMap<String, String> getFooters(ChangeData cd) {
+    HashMap<String, String> footers = new HashMap<>();
+    cd.commitFooters()
+        .forEach(footerLine -> footers.put(footerLine.getKey(), footerLine.getValue()));
+    return ImmutableMap.copyOf(footers);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index d8946a7..749a241 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -51,6 +52,10 @@
   @Option(name = "--path")
   private String path;
 
+  /** 1-based index of the parent's position in the commit object. */
+  @Option(name = "--parent", metaVar = "parent-number")
+  private Integer parentNum;
+
   @Inject
   GetPatch(GitRepositoryManager repoManager) {
     this.repoManager = repoManager;
@@ -58,7 +63,8 @@
 
   @Override
   public Response<BinaryResult> apply(RevisionResource rsrc)
-      throws ResourceConflictException, IOException, ResourceNotFoundException {
+      throws BadRequestException, ResourceConflictException, IOException,
+          ResourceNotFoundException {
     final Repository repo = repoManager.openRepository(rsrc.getProject());
     boolean close = true;
     try {
@@ -67,12 +73,16 @@
       try {
         final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         RevCommit[] parents = commit.getParents();
-        if (parents.length > 1) {
+        if (parentNum == null && parents.length > 1) {
           throw new ResourceConflictException("Revision has more than 1 parent.");
-        } else if (parents.length == 0) {
+        }
+        if (parents.length == 0) {
           throw new ResourceConflictException("Revision has no parent.");
         }
-        final RevCommit base = parents[0];
+        if (parentNum != null && (parentNum < 1 || parentNum > parents.length)) {
+          throw new BadRequestException(String.format("invalid parent number: %d", parentNum));
+        }
+        final RevCommit base = parents[parentNum == null ? 0 : parentNum - 1];
         rw.parseBody(base);
 
         bin =
diff --git a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
new file mode 100644
index 0000000..60c8e89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Optional;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** A utility class for creating a patch set on an existing change. */
+@Singleton
+public class PatchSetCreator {
+  private final Provider<IdentifiedUser> ident;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final ChangeJson.Factory jsonFactory;
+  private final ZoneId serverZoneId;
+
+  @Inject
+  PatchSetCreator(
+      Provider<IdentifiedUser> ident,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      @GerritPersonIdent PersonIdent myIdent,
+      ChangeJson.Factory jsonFactory) {
+    this.ident = ident;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+    this.serverZoneId = myIdent.getZoneId();
+    this.jsonFactory = jsonFactory;
+  }
+
+  public ChangeInfo createPatchSetWithSuppliedTree(
+      Project.NameKey project,
+      ChangeData destChange,
+      RevCommit latestPatchset,
+      List<RevCommit> parents,
+      @Nullable AccountInput author,
+      List<ListChangesOption> outputOptions,
+      Repository repo,
+      ObjectInserter oi,
+      CodeReviewRevWalk revWalk,
+      ObjectId commitTree,
+      String commitMessage)
+      throws IOException, RestApiException, UpdateException {
+    requireNonNull(destChange);
+    requireNonNull(latestPatchset);
+    requireNonNull(parents);
+    requireNonNull(outputOptions);
+
+    Instant now = TimeUtil.now();
+    PersonIdent committerIdent =
+        Optional.ofNullable(latestPatchset.getCommitterIdent())
+            .map(
+                id ->
+                    ident
+                        .get()
+                        .newCommitterIdent(id.getEmailAddress(), now, serverZoneId)
+                        .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId)))
+            .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId));
+    PersonIdent authorIdent =
+        author == null
+            ? committerIdent
+            : new PersonIdent(author.name, author.email, now, serverZoneId);
+
+    ObjectId appliedCommit =
+        CommitUtil.createCommitWithTree(
+            oi, authorIdent, committerIdent, parents, commitMessage, commitTree);
+    CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
+    oi.flush();
+
+    Change resultChange;
+    try (BatchUpdate bu = batchUpdateFactory.create(project, ident.get(), TimeUtil.now())) {
+      bu.setRepository(repo, revWalk, oi);
+      resultChange = insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
+    } catch (NoSuchChangeException | RepositoryNotFoundException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+
+    return jsonFactory.create(outputOptions).format(resultChange);
+  }
+
+  public void validateChangeCanBeAppended(@Nullable ChangeData destChange, BranchNameKey destBranch)
+      throws PreconditionFailedException {
+    if (destChange == null) {
+      throw new PreconditionFailedException(
+          "cannot write a patch set without a destination change.");
+    }
+
+    if (destChange.change().isClosed()) {
+      throw new PreconditionFailedException(
+          String.format(
+              "patch:apply with Change-Id %s could not update the existing change %d "
+                  + "in destination branch %s of project %s, because the change was closed (%s)",
+              destChange.getId(),
+              destChange.getId().get(),
+              destBranch.branch(),
+              destBranch.project(),
+              destChange.change().getStatus().name()));
+    }
+  }
+
+  private static Change insertPatchSet(
+      BatchUpdate bu,
+      Repository git,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      ChangeNotes destNotes,
+      CodeReviewCommit commit)
+      throws IOException, UpdateException, RestApiException {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      Change destChange = destNotes.getChange();
+      PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+      PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
+      inserter.setMessage(buildMessageForPatchSet(psId));
+      bu.addOp(destChange.getId(), inserter);
+      bu.execute();
+      return inserter.getChange();
+    }
+  }
+
+  private static String buildMessageForPatchSet(PatchSet.Id psId) {
+    return String.format("Uploaded patch set %s.", psId.get());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index f54df5b..3c30b84 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -94,6 +94,8 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdates;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -143,10 +145,9 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  private final BatchUpdate.Factory updateFactory;
+  private final RetryHelper retryHelper;
   private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
-  private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
   private final DraftCommentsReader draftCommentsReader;
@@ -168,10 +169,9 @@
 
   @Inject
   PostReview(
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
-      ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
       DraftCommentsReader draftCommentsReader,
@@ -188,10 +188,9 @@
       ReviewerAdded reviewerAdded,
       ChangeJson.Factory changeJsonFactory,
       CommentsValidator commentsValidator) {
-    this.updateFactory = updateFactory;
+    this.retryHelper = retryHelper;
     this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
-    this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.draftCommentsReader = draftCommentsReader;
     this.approvalsUtil = approvalsUtil;
@@ -293,99 +292,58 @@
     }
     output.labels = input.labels;
 
-    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-      try (BatchUpdate bu =
-          updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
-        bu.setNotify(notify);
-
-        Account account = revision.getUser().asIdentifiedUser().getAccount();
-        boolean ccOrReviewer = false;
-        if (input.labels != null && !input.labels.isEmpty()) {
-          ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
-          if (ccOrReviewer) {
-            logger.atFine().log(
-                "calling user is cc/reviewer on the change due to voting on a label");
-          }
-        }
-
-        if (!ccOrReviewer) {
-          // Check if user was already CCed or reviewing prior to this review.
-          ReviewerSet currentReviewers =
-              approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
-          ccOrReviewer = currentReviewers.all().contains(account.id());
-          if (ccOrReviewer) {
-            logger.atFine().log("calling user is already cc/reviewer on the change");
-          }
-        }
-
-        // Apply reviewer changes first. Revision emails should be sent to the
-        // updated set of reviewers. Also keep track of whether the user added
-        // themselves as a reviewer or to the CC list.
-        logger.atFine().log("adding reviewer additions");
-        for (ReviewerModification reviewerResult : reviewerResults) {
-          reviewerResult.op.suppressEmail(); // Send a single batch email below.
-          reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
-          bu.addOp(revision.getChange().getId(), reviewerResult.op);
-          if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
-            logger.atFine().log("calling user is explicitly added as reviewer or CC");
-            ccOrReviewer = true;
-          }
-        }
-
-        if (!ccOrReviewer) {
-          // User posting this review isn't currently in the reviewer or CC list,
-          // isn't being explicitly added, and isn't voting on any label.
-          // Automatically CC them on this change so they receive replies.
-          logger.atFine().log("CCing calling user");
-          ReviewerModification selfAddition =
-              reviewerModifier.ccCurrentUser(revision.getUser(), revision);
-          selfAddition.op.suppressEmail();
-          selfAddition.op.suppressEvent();
-          bu.addOp(revision.getChange().getId(), selfAddition.op);
-        }
-
-        // Add WorkInProgressOp if requested.
-        if ((input.ready || input.workInProgress)
-            && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
-          if (input.ready && input.workInProgress) {
-            output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
-            return Response.withStatusCode(SC_BAD_REQUEST, output);
-          }
-
-          revision
-              .getChangeResource()
-              .permissions()
-              .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-
-          if (input.ready) {
-            output.ready = true;
-          }
-
-          logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
-          WorkInProgressOp wipOp =
-              workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
-          wipOp.suppressEmail();
-          bu.addOp(revision.getChange().getId(), wipOp);
-        }
-
-        // Add the review ops.
-        logger.atFine().log("posting review");
-        PostReviewOp postReviewOp =
-            postReviewOpFactory.create(
-                projectState, revision.getPatchSet().id(), input, revision.getAccountId());
-        bu.addOp(revision.getChange().getId(), postReviewOp);
-
-        // Adjust the attention set based on the input
-        replyAttentionSetUpdates.updateAttentionSetOnPostReview(
-            bu, postReviewOp, revision.getNotes(), input, revision.getUser());
-        bu.execute();
+    Account account = revision.getUser().asIdentifiedUser().getAccount();
+    boolean ccOrReviewer = false;
+    if (input.labels != null && !input.labels.isEmpty()) {
+      ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
+      if (ccOrReviewer) {
+        logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
       }
     }
 
-    // Re-read change to take into account results of the update.
-    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    if (!ccOrReviewer) {
+      // Check if user was already CCed or reviewing prior to this review.
+      ReviewerSet currentReviewers =
+          approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
+      ccOrReviewer = currentReviewers.all().contains(account.id());
+      if (ccOrReviewer) {
+        logger.atFine().log("calling user is already cc/reviewer on the change");
+      }
+    }
+
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.op.suppressEmail(); // Send a single batch email below.
+      reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
+      if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
+        logger.atFine().log("calling user is explicitly added as reviewer or CC");
+        ccOrReviewer = true;
+      }
+    }
+
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
+    if ((input.ready || input.workInProgress)
+        && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+      if (input.ready && input.workInProgress) {
+        output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
+        return Response.withStatusCode(SC_BAD_REQUEST, output);
+      }
+
+      revision
+          .getChangeResource()
+          .permissions()
+          .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
+
+      if (input.ready) {
+        output.ready = true;
+      }
+    }
+
+    BatchUpdates.Result batchUpdateResult =
+        runBatchUpdate(projectState, revision, input, ts, notify, reviewerResults, ccOrReviewer);
+    ChangeData cd =
+        batchUpdateResult.getChangeData(revision.getProject(), revision.getChange().getId());
     for (ReviewerModification reviewerResult : reviewerResults) {
       reviewerResult.gatherResults(cd);
     }
@@ -397,11 +355,83 @@
 
     if (input.responseFormatOptions != null) {
       output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd);
+    } else {
+      output.changeInfo = changeJsonFactory.noOptions().format(cd);
     }
 
     return Response.ok(output);
   }
 
+  private BatchUpdates.Result runBatchUpdate(
+      ProjectState projectState,
+      RevisionResource revision,
+      ReviewInput input,
+      Instant ts,
+      NotifyResolver.Result notify,
+      List<ReviewerModification> reviewerResults,
+      boolean ccOrReviewer)
+      throws UpdateException, RestApiException {
+    return retryHelper
+        .changeUpdate(
+            "batchUpdate",
+            updateFactory -> {
+              try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+                try (BatchUpdate bu =
+                    updateFactory.create(
+                        revision.getChange().getProject(), revision.getUser(), ts)) {
+                  bu.setNotify(notify);
+
+                  // Apply reviewer changes first. Revision emails should be sent to the
+                  // updated set of reviewers. Also keep track of whether the user added
+                  // themselves as a reviewer or to the CC list.
+                  logger.atFine().log("adding reviewer additions");
+                  reviewerResults.forEach(
+                      reviewerResult -> bu.addOp(revision.getChange().getId(), reviewerResult.op));
+
+                  if (!ccOrReviewer) {
+                    // User posting this review isn't currently in the reviewer or CC list,
+                    // isn't being explicitly added, and isn't voting on any label.
+                    // Automatically CC them on this change so they receive replies.
+                    logger.atFine().log("CCing calling user");
+                    ReviewerModification selfAddition =
+                        reviewerModifier.ccCurrentUser(revision.getUser(), revision);
+                    selfAddition.op.suppressEmail();
+                    selfAddition.op.suppressEvent();
+                    bu.addOp(revision.getChange().getId(), selfAddition.op);
+                  }
+
+                  // Add WorkInProgressOp if requested.
+                  if ((input.ready || input.workInProgress)
+                      && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+                    logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
+                    WorkInProgressOp wipOp =
+                        workInProgressOpFactory.create(
+                            input.workInProgress, new WorkInProgressOp.Input());
+                    wipOp.suppressEmail();
+                    bu.addOp(revision.getChange().getId(), wipOp);
+                  }
+
+                  // Add the review ops.
+                  logger.atFine().log("posting review");
+                  PostReviewOp postReviewOp =
+                      postReviewOpFactory.create(
+                          projectState,
+                          revision.getPatchSet().id(),
+                          input,
+                          revision.getAccountId());
+                  bu.addOp(revision.getChange().getId(), postReviewOp);
+
+                  // Adjust the attention set based on the input
+                  replyAttentionSetUpdates.updateAttentionSetOnPostReview(
+                      bu, postReviewOp, revision.getNotes(), input, revision.getUser());
+
+                  return bu.execute();
+                }
+              }
+            })
+        .call();
+  }
+
   private boolean didWorkInProgressChange(boolean currentWorkInProgress, ReviewInput input) {
     return input.ready == currentWorkInProgress || input.workInProgress != currentWorkInProgress;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 490ff490..511cb17 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -425,11 +425,14 @@
   }
 
   /**
-   * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
-   * neither in {@code existingComments} nor in {@code drafts}.
+   * Returns the subset of {@code inputComments} that should be added to the change.
    *
-   * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
-   * removed.
+   * <p>If the matching comment (with the same id) already exists in {@code existingComments} then
+   * the comment is filtered out. This assumes that the comment has been already published earlier.
+   *
+   * <p>If the matching comment is found in {@code drafts}, then it's removed from drafts and the
+   * comment is kept in the output. This assumes that the comment in the input is the newer version
+   * of the previously existing draft.
    *
    * @param inputComments new comments provided as {@link CommentInput} entries in the API.
    * @param existingComments existing published comments in the database.
@@ -464,6 +467,7 @@
           comment.writtenOn = Timestamp.from(ctx.getWhen());
           comment.side = inputComment.side();
           comment.message = inputComment.message;
+          comment.unresolved = inputComment.unresolved;
         }
 
         commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewFix.java b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
index e771898..042f73d 100644
--- a/java/com/google/gerrit/server/restapi/change/PreviewFix.java
+++ b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
@@ -128,6 +128,11 @@
       if (applyProvidedFixInput.fixReplacementInfos == null) {
         throw new BadRequestException("applyProvidedFixInput.fixReplacementInfos is required");
       }
+      if (applyProvidedFixInput.originalPatchsetForFix != null
+          && applyProvidedFixInput.originalPatchsetForFix > 0) {
+        throw new BadRequestException(
+            "applyProvidedFixInput.originalPatchsetForFix is not supported on preview.");
+      }
 
       PreviewFix previewFix = previewFixFactory.create(revisionResource);
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index ddec6bd..91f1575 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -174,7 +174,7 @@
       String commitMessage,
       Instant timestamp,
       String committerEmail)
-      throws IOException, BadRequestException, ResourceConflictException {
+      throws IOException, BadRequestException {
     CommitBuilder builder = new CommitBuilder();
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 812711a..2878fe2 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -155,6 +156,7 @@
       throws BadRequestException, AuthException, PermissionBackendException {
     List<List<ChangeInfo>> out;
     try {
+      applyPermissionBackendFilter();
       out = query();
     } catch (QueryRequiresAuthException e) {
       throw new AuthException("Must be signed-in to use this operator", e);
@@ -165,6 +167,22 @@
     return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
+  private void applyPermissionBackendFilter() {
+    String queryFilter = permissionBackend.currentUser().filterQueryChanges();
+    if (Strings.isNullOrEmpty(queryFilter)) {
+      return;
+    }
+
+    if (queries == null || queries.isEmpty()) {
+      addQuery(queryFilter);
+      return;
+    }
+
+    for (int i = 0; i < queries.size(); i++) {
+      queries.set(i, "(" + queries.get(i) + ") AND (" + queryFilter + ")");
+    }
+  }
+
   private List<List<ChangeInfo>> query()
       throws BadRequestException, QueryParseException, PermissionBackendException {
     ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
index 9fb8de8..ad35b37 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
@@ -14,42 +14,70 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.api.changes.RebaseChangeEditInput;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
 import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class RebaseChangeEdit implements RestModifyView<ChangeResource, Input> {
+public class RebaseChangeEdit implements RestModifyView<ChangeResource, RebaseChangeEditInput> {
   private final GitRepositoryManager repositoryManager;
   private final ChangeEditModifier editModifier;
+  private final ChangeEditUtil editUtil;
+  private final ChangeEditJson editJson;
 
   @Inject
-  RebaseChangeEdit(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
+  RebaseChangeEdit(
+      GitRepositoryManager repositoryManager,
+      ChangeEditModifier editModifier,
+      ChangeEditUtil editUtil,
+      ChangeEditJson editJson) {
     this.repositoryManager = repositoryManager;
     this.editModifier = editModifier;
+    this.editUtil = editUtil;
+    this.editJson = editJson;
   }
 
   @Override
-  public Response<Object> apply(ChangeResource rsrc, Input in)
+  public Response<EditInfo> apply(ChangeResource rsrc, RebaseChangeEditInput input)
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
+    if (input == null) {
+      input = new RebaseChangeEditInput();
+    }
+
     Project.NameKey project = rsrc.getProject();
     try (Repository repository = repositoryManager.openRepository(project)) {
-      editModifier.rebaseEdit(repository, rsrc.getNotes());
+      CodeReviewCommit rebasedChangeEditCommit =
+          editModifier.rebaseEdit(repository, rsrc.getNotes(), input);
+
+      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
+      checkState(edit.isPresent(), "change edit missing after rebase");
+      EditInfo editInfo = editJson.toEditInfo(edit.get(), /* downloadCommands= */ false);
+      if (!rebasedChangeEditCommit.getFilesWithGitConflicts().isEmpty()) {
+        editInfo.containsGitConflicts = true;
+      }
+      return Response.ok(editInfo);
     } catch (InvalidChangeOperationException e) {
       throw new ResourceConflictException(e.getMessage());
     }
-    return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index bc47adc..2803c0e 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
@@ -25,6 +27,8 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -48,15 +52,17 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -76,6 +82,7 @@
   private final ServiceUserClassifier serviceUserClassifier;
   private final CommentsUtil commentsUtil;
   private final DraftCommentsReader draftCommentsReader;
+  private final ProjectCache projectCache;
 
   @Inject
   ReplyAttentionSetUpdates(
@@ -86,7 +93,8 @@
       AccountResolver accountResolver,
       ServiceUserClassifier serviceUserClassifier,
       CommentsUtil commentsUtil,
-      DraftCommentsReader draftCommentsReader) {
+      DraftCommentsReader draftCommentsReader,
+      ProjectCache projectCache) {
     this.permissionBackend = permissionBackend;
     this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
     this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
@@ -95,6 +103,7 @@
     this.serviceUserClassifier = serviceUserClassifier;
     this.commentsUtil = commentsUtil;
     this.draftCommentsReader = draftCommentsReader;
+    this.projectCache = projectCache;
   }
 
   /** Adjusts the attention set but only based on the automatic rules. */
@@ -235,7 +244,7 @@
 
     addOwnerAndUploaderToAttentionSetIfSomeoneElseReplied(
         bu, postReviewOp, changeNotes, currentUser, readyForReview, allNewComments);
-    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
+    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments, currentUser);
   }
 
   /**
@@ -329,17 +338,71 @@
 
   /** Adds all authors of all comment threads that received a reply during this update */
   private void addAllAuthorsOfCommentThreads(
-      BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
-    List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
-    ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
-        CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      ImmutableSet<HumanComment> allNewComments,
+      CurrentUser currentUser) {
+    boolean isOwnerOrUploader =
+        currentUser.getAccountId().equals(changeNotes.getChange().getOwner())
+            || currentUser.getAccountId().equals(changeNotes.getCurrentPatchSet().uploader());
 
-    ImmutableSet<Account.Id> repliedToUsers =
-        repliedToCommentThreads.stream()
-            .map(CommentThread::comments)
-            .flatMap(Collection::stream)
-            .map(comment -> comment.author.getId())
-            .collect(toImmutableSet());
+    boolean noCRLabel = false;
+    Optional<LabelValue> maxCRValue =
+        projectCache
+            .get(changeNotes.getChange().getProject())
+            .orElseThrow(
+                () ->
+                    new IllegalStateException(
+                        String.format(
+                            "Couldn't find project \"%s\" for a change \"%s\"",
+                            changeNotes.getChange().getProject(), changeNotes.getChangeId())))
+            .getLabelTypes(changeNotes)
+            .byLabel(LabelId.CODE_REVIEW)
+            .map(l -> l.getMax());
+
+    ImmutableSet<Account.Id> maxCrApprovers;
+    if (maxCRValue.isPresent()) {
+      maxCrApprovers =
+          changeNotes.getApprovals().all().get(changeNotes.getCurrentPatchSet().id()).stream()
+              .filter(
+                  a ->
+                      a.label().equals(LabelId.CODE_REVIEW)
+                          && a.value() == maxCRValue.get().getValue())
+              .map(a -> a.accountId())
+              .collect(toImmutableSet());
+    } else {
+      noCRLabel = true;
+      maxCrApprovers = ImmutableSet.of();
+    }
+
+    // Include newly published comments, when building threads.
+    ImmutableList<HumanComment> relevantComments =
+        Stream.concat(
+                commentsUtil.publishedHumanCommentsByChange(changeNotes).stream(),
+                allNewComments.stream())
+            .collect(toImmutableList());
+    ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
+        CommentThreads.forComments(relevantComments).getThreadsForChildren(allNewComments);
+
+    LinkedHashSet<Account.Id> repliedToUsers = new LinkedHashSet<>();
+    for (CommentThread<HumanComment> thread : repliedToCommentThreads) {
+      // If thread is resolved, we only bring back the commenters who have not yet left max
+      // Code-Review vote.
+      // If Owner replied but didn't resolve, we assume clarification was asked add everyone on the
+      // thread to attention set.
+      boolean ignoreVoteCheck = noCRLabel || (thread.unresolved() && isOwnerOrUploader);
+      if (thread.unresolved() && !isOwnerOrUploader) {
+        // Reviewer replied. Owner is still the one to act. No need to add commenters.
+        continue;
+      }
+      thread.comments().stream()
+          .map(comment -> comment.author.getId())
+          .filter(
+              a ->
+                  !a.equals(currentUser.getAccountId())
+                      && (ignoreVoteCheck || !maxCrApprovers.contains(a)))
+          .forEach(repliedToUsers::add);
+    }
     ImmutableSet<Account.Id> possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
     SetView<Account.Id> usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 05648d5..60b5600 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.flogger.LazyArgs.lazy;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
@@ -242,8 +241,7 @@
             visibilityControl,
             excludeGroups,
             filteredRecommendations);
-    logger.atFine().log(
-        "Suggested reviewers: %s", lazy(() -> formatSuggestedReviewers(suggestedReviewers)));
+    logger.atFine().log("Suggested reviewers: %s", formatSuggestedReviewers(suggestedReviewers));
     return suggestedReviewers;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 36b859c..6b35175 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.joining;
@@ -71,10 +72,10 @@
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -86,6 +87,14 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * REST API handler for triggering submit of the specific revision of the change.
+ *
+ * <p>See /Documentation/rest-api-changes.html#submit-revision for more information.
+ *
+ * <p>Even though the endpoint is defined for url including a revision, only revision corresponding
+ * to the latest patch set is allowed.
+ */
 @Singleton
 public class Submit
     implements RestModifyView<RevisionResource, SubmitInput>, UiAction<RevisionResource> {
@@ -99,8 +108,6 @@
       "Submit all ${topicSize} changes of the same topic "
           + "(${submitSize} changes including ancestors and other "
           + "changes related by topic)";
-  private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
-      "This change depends on other hidden changes which are not ready";
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
 
@@ -180,12 +187,12 @@
       input = new SubmitInput();
     }
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
-    IdentifiedUser submitter;
+    IdentifiedUser submitter = rsrc.getUser().asIdentifiedUser();
+    // It's possible that the user does not have permission to submit all changes in the superset,
+    // but we check the current change for an early exit.
+    rsrc.permissions().check(ChangePermission.SUBMIT);
     if (input.onBehalfOf != null) {
       submitter = onBehalfOf(rsrc, input);
-    } else {
-      rsrc.permissions().check(ChangePermission.SUBMIT);
-      submitter = rsrc.getUser().asIdentifiedUser();
     }
     projectCache
         .get(rsrc.getProject())
@@ -213,9 +220,7 @@
     }
 
     try (MergeOp op = mergeOpProvider.get()) {
-      Change updatedChange;
-
-      updatedChange = op.merge(change, submitter, true, input, false);
+      Change updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
         return updatedChange;
       }
@@ -240,48 +245,15 @@
    */
   @Nullable
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
-    try {
-      if (cs.furtherHiddenChanges()) {
-        logger.atFine().log(
-            "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
-            cd.getId().get(), user.getLoggableName(), cs.nonVisibleChanges());
-        return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
-      }
-      for (ChangeData c : cs.changes()) {
-        Set<ChangePermission> can =
-            permissionBackend
-                .user(user)
-                .change(c)
-                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
-        if (!can.contains(ChangePermission.READ)) {
-          logger.atFine().log(
-              "Change %d cannot be submitted by user %s because it depends on change %d which the user cannot read",
-              cd.getId().get(), user.getLoggableName(), c.getId().get());
-          return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
-        }
-        if (!can.contains(ChangePermission.SUBMIT)) {
-          return "You don't have permission to submit change " + c.getId();
-        }
-        if (c.change().isWorkInProgress()) {
-          return "Change " + c.getId() + " is marked work in progress";
-        }
-        try {
-          // 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 (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());
-        }
-      }
+    Optional<String> reason =
+        MergeOp.checkCommonSubmitProblems(cd.change(), cs, false, permissionBackend, user).stream()
+            .findFirst()
+            .map(MergeOp.ChangeProblem::getProblem);
+    if (reason.isPresent()) {
+      return reason.get();
+    }
 
+    try {
       if (!useMergeabilityCheck) {
         return null;
       }
@@ -298,7 +270,7 @@
         return "Problems with change(s): "
             + unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
       }
-    } catch (PermissionBackendException | IOException e) {
+    } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error checking if change is submittable");
       throw new StorageException("Could not determine problems for the change", e);
     }
@@ -330,7 +302,16 @@
     ChangeSet cs =
         mergeSuperSet
             .get()
-            .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
+            .completeChangeSet(cd.change(), resource.getUser(), /* includingTopicClosure= */ false);
+    // Replace potentially stale ChangeData for the current change with the fresher one.
+    cs =
+        new ChangeSet(
+            cs.changes().stream()
+                .map(csChange -> csChange.getId().equals(cd.getId()) ? cd : csChange)
+                .collect(toImmutableList()),
+            cs.nonVisibleChanges());
+    String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
+
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -338,8 +319,6 @@
     }
     boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
 
-    String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
-
     if (submitProblems != null) {
       return new UiAction.Description()
           .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
@@ -464,7 +443,8 @@
       throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
           ConfigInvalidException {
     PermissionBackend.ForChange perm = rsrc.permissions();
-    perm.check(ChangePermission.SUBMIT);
+    // It's possible that the current user or on-behalf-of user does not have permission for all
+    // changes in the superset, but we check the current change for an early exit.
     perm.check(ChangePermission.SUBMIT_AS);
 
     CurrentUser caller = rsrc.getUser();
@@ -476,9 +456,17 @@
       throw new UnprocessableEntityException(
           String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()), e);
     }
+    logger.atFine().log(
+        "Change %d is being submitted by %s on behalf of %s",
+        rsrc.getChange().getChangeId(), rsrc.getUser().getUserName(), submitter.getUserName());
     return submitter;
   }
 
+  /**
+   * Change-level REST API endpoint that calls submit for the latest revision on a change.
+   *
+   * <p>See /Documentation/rest-api-changes.html#submit-change for more information.
+   */
   public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
     private final Submit submit;
     private final PatchSetUtil psUtil;
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 0035a03..b09ce21 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -29,9 +29,9 @@
 public class SuggestReviewers {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final int DEFAULT_MAX_SUGGESTED = 10;
+  public static final boolean DEFAULT_SKIP_SERVICE_USERS = true;
 
-  private static final boolean DEFAULT_SKIP_SERVICE_USERS = true;
+  private static final int DEFAULT_MAX_SUGGESTED = 10;
 
   protected final ReviewersUtil reviewersUtil;
 
diff --git a/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java b/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java
new file mode 100644
index 0000000..ca366f4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.AccountDeactivator;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class AccountDeactivation implements RestModifyView<ConfigResource, Input> {
+  private final AccountDeactivator deactivator;
+  private final WorkQueue workQueue;
+
+  @Inject
+  AccountDeactivation(WorkQueue workQueue, AccountDeactivator deactivator) {
+    this.deactivator = deactivator;
+    this.workQueue = workQueue;
+  }
+
+  @Override
+  public Response<?> apply(ConfigResource rsrc, Input unusedInput) {
+    if (taskAlreadyScheduled()) {
+      return Response.ok("Account deactivator already in queue.");
+    }
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(() -> deactivator.run());
+    return Response.accepted("Account deactivator task added to work queue.");
+  }
+
+  private boolean taskAlreadyScheduled() {
+    for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+      if (task.toString().contains(deactivator.getClass().getName())) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/CleanupChanges.java b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
new file mode 100644
index 0000000..9ae3637
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.restapi.config.CleanupChanges.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+@Singleton
+public class CleanupChanges implements RestModifyView<ConfigResource, Input> {
+  private final ChangeCleanupRunner.Factory runnerFactory;
+  private final WorkQueue workQueue;
+
+  public static class Input {
+    String after;
+    boolean ifMergeable;
+    String message;
+  }
+
+  @Inject
+  CleanupChanges(WorkQueue workQueue, ChangeCleanupRunner.Factory runnerFactory) {
+    this.runnerFactory = runnerFactory;
+    this.workQueue = workQueue;
+  }
+
+  @Override
+  public Response<?> apply(ConfigResource rsrc, Input input) throws BadRequestException {
+    if (taskAlreadyScheduled()) {
+      return Response.ok("Change cleaner already in queue.");
+    }
+    if (input.after == null) {
+      throw new BadRequestException("`after` must be specified.");
+    }
+    ChangeCleanupRunner runner =
+        runnerFactory.create(
+            ConfigUtil.getTimeUnit(input.after, 0, TimeUnit.MILLISECONDS),
+            input.ifMergeable,
+            input.message);
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(() -> runner.run());
+    return Response.accepted("Change cleaner task added to work queue.");
+  }
+
+  private boolean taskAlreadyScheduled() {
+    for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+      if (task.toString().contains(ChangeCleanupRunner.class.getName())) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index 77caab4..2f21da6 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
+import static com.google.gerrit.server.config.ExperimentResource.EXPERIMENT_KIND;
 import static com.google.gerrit.server.config.IndexResource.INDEX_KIND;
+import static com.google.gerrit.server.config.IndexVersionResource.INDEX_VERSION_KIND;
 import static com.google.gerrit.server.config.TaskResource.TASK_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -28,13 +30,20 @@
   protected void configure() {
     DynamicMap.mapOf(binder(), CapabilityResource.CAPABILITY_KIND);
     DynamicMap.mapOf(binder(), CONFIG_KIND);
+    DynamicMap.mapOf(binder(), EXPERIMENT_KIND);
     DynamicMap.mapOf(binder(), TASK_KIND);
     DynamicMap.mapOf(binder(), TopMenuResource.TOP_MENU_KIND);
     DynamicMap.mapOf(binder(), INDEX_KIND);
+    DynamicMap.mapOf(binder(), INDEX_VERSION_KIND);
 
     child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
     post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    post(CONFIG_KIND, "deactivate.stale.accounts").to(AccountDeactivation.class);
     put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
+
+    child(CONFIG_KIND, "experiments").to(ExperimentsCollection.class);
+    get(EXPERIMENT_KIND).to(GetExperiment.class);
+
     post(CONFIG_KIND, "index.changes").to(IndexChanges.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
@@ -44,6 +53,8 @@
     get(CONFIG_KIND, "preferences.edit").to(GetEditPreferences.class);
     put(CONFIG_KIND, "preferences.edit").to(SetEditPreferences.class);
     post(CONFIG_KIND, "reload").to(ReloadConfig.class);
+    post(CONFIG_KIND, "snapshot.indexes").to(SnapshotIndexes.class);
+    post(CONFIG_KIND, "cleanup.changes").to(CleanupChanges.class);
 
     child(CONFIG_KIND, "tasks").to(TasksCollection.class);
     delete(TASK_KIND).to(DeleteTask.class);
@@ -53,7 +64,13 @@
     get(CONFIG_KIND, "version").to(GetVersion.class);
 
     child(CONFIG_KIND, "indexes").to(IndexCollection.class);
-    put(INDEX_KIND, "snapshot").to(SnapshotIndex.class);
+    post(INDEX_KIND, "snapshot").to(SnapshotIndex.class);
+    get(INDEX_KIND).to(GetIndex.class);
+
+    child(INDEX_KIND, "versions").to(IndexVersionsCollection.class);
+    get(INDEX_VERSION_KIND).to(GetIndexVersion.class);
+    post(INDEX_VERSION_KIND, "snapshot").to(SnapshotIndexVersion.class);
+    post(INDEX_VERSION_KIND, "reindex").to(ReindexIndexVersion.class);
 
     // The caches and summary REST endpoints are bound via RestCacheAdminModule.
   }
diff --git a/java/com/google/gerrit/server/restapi/config/ExperimentsCollection.java b/java/com/google/gerrit/server/restapi/config/ExperimentsCollection.java
new file mode 100644
index 0000000..0fb141c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ExperimentsCollection.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ExperimentResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.Inject;
+
+public class ExperimentsCollection implements ChildCollection<ConfigResource, ExperimentResource> {
+  private final PermissionBackend permissionBackend;
+  private final DynamicMap<RestView<ExperimentResource>> views;
+  private final ListExperiments list;
+
+  @Inject
+  ExperimentsCollection(
+      PermissionBackend permissionBackend,
+      DynamicMap<RestView<ExperimentResource>> views,
+      ListExperiments list) {
+    this.permissionBackend = permissionBackend;
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() throws RestApiException {
+    return list;
+  }
+
+  @Override
+  public ExperimentResource parse(ConfigResource parent, IdString id)
+      throws ResourceNotFoundException, Exception {
+    permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    if (ListExperiments.getExperiments().stream().noneMatch(id.get()::equalsIgnoreCase)) {
+      throw new ResourceNotFoundException(id.get());
+    }
+
+    return new ExperimentResource(id.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<ExperimentResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetExperiment.java b/java/com/google/gerrit/server/restapi/config/GetExperiment.java
new file mode 100644
index 0000000..1c3bd0a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetExperiment.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ExperimentResource;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetExperiment implements RestReadView<ExperimentResource> {
+  private final ExperimentFeatures experimentFeatures;
+
+  @Inject
+  public GetExperiment(ExperimentFeatures experimentFeatures) {
+    this.experimentFeatures = experimentFeatures;
+  }
+
+  @Override
+  public Response<ExperimentInfo> apply(ExperimentResource resource) {
+    return Response.ok(getExperimentInfo(resource.getName()));
+  }
+
+  public ExperimentInfo getExperimentInfo(String experimentName) {
+    ExperimentInfo experimentInfo = new ExperimentInfo();
+    experimentInfo.enabled = experimentFeatures.isFeatureEnabled(experimentName);
+    return experimentInfo;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetIndex.java b/java/com/google/gerrit/server/restapi/config/GetIndex.java
new file mode 100644
index 0000000..c96c66e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetIndex.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.IndexResource;
+
+public class GetIndex implements RestReadView<IndexResource> {
+
+  @Override
+  public Response<IndexInfo> apply(IndexResource rsrc)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    return Response.ok(IndexInfo.fromIndexDefinition(rsrc.getIndexDefinition()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetIndexVersion.java b/java/com/google/gerrit/server/restapi/config/GetIndexVersion.java
new file mode 100644
index 0000000..98cf7e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetIndexVersion.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.restapi.config.IndexInfo.IndexVersionInfo;
+
+public class GetIndexVersion implements RestReadView<IndexVersionResource> {
+
+  @Override
+  public Response<IndexVersionInfo> apply(IndexVersionResource rsrc)
+      throws ResourceNotFoundException {
+    IndexCollection<?, ?, ?> indexCollection = rsrc.getIndexDefinition().getIndexCollection();
+    Index<?, ?> i = rsrc.getIndex();
+    int version = i.getSchema().getVersion();
+    boolean isSearch = indexCollection.getSearchIndex().getSchema().getVersion() == version;
+    boolean isWrite = indexCollection.getWriteIndex(version) != null;
+    return Response.ok(IndexInfo.IndexVersionInfo.create(isWrite, isSearch, i.numDocs()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index a9f16e7..e6b1293 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.ContributorAgreement;
@@ -29,6 +31,7 @@
 import com.google.gerrit.extensions.common.DownloadInfo;
 import com.google.gerrit.extensions.common.DownloadSchemeInfo;
 import com.google.gerrit.extensions.common.GerritInfo;
+import com.google.gerrit.extensions.common.MetadataInfo;
 import com.google.gerrit.extensions.common.PluginConfigInfo;
 import com.google.gerrit.extensions.common.ReceiveInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
@@ -42,6 +45,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.EnableSignedPush;
+import com.google.gerrit.server.ServerStateProvider;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.avatar.AvatarProvider;
@@ -68,6 +72,7 @@
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -94,6 +99,7 @@
   private final AgreementJson agreementJson;
   private final SitePaths sitePaths;
   private final @Nullable @GerritInstanceId String instanceId;
+  private final PluginSetContext<ServerStateProvider> serverStateProviders;
 
   @Inject
   public GetServerInfo(
@@ -116,7 +122,8 @@
       ProjectCache projectCache,
       AgreementJson agreementJson,
       SitePaths sitePaths,
-      @Nullable @GerritInstanceId String instanceId) {
+      @Nullable @GerritInstanceId String instanceId,
+      PluginSetContext<ServerStateProvider> serverStateProviders) {
     this.config = config;
     this.accountVisibilityProvider = accountVisibilityProvider;
     this.accountDefaultDisplayName = accountDefaultDisplayName;
@@ -137,6 +144,7 @@
     this.agreementJson = agreementJson;
     this.sitePaths = sitePaths;
     this.instanceId = instanceId;
+    this.serverStateProviders = serverStateProviders;
   }
 
   @Override
@@ -156,6 +164,7 @@
     info.user = getUserInfo();
     info.receive = getReceiveInfo();
     info.submitRequirementDashboardColumns = getSubmitRequirementDashboardColumns();
+    info.metadata = getMetadata();
     return Response.ok(info);
   }
 
@@ -296,6 +305,8 @@
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
     info.instanceId = instanceId;
     info.defaultBranch = config.getString("gerrit", null, "defaultBranch");
+    info.projectStatePredicateEnabled =
+        config.getBoolean("gerrit", null, "projectStatePredicateEnabled", true);
     return info;
   }
 
@@ -376,6 +387,18 @@
     return Arrays.asList(config.getStringList("dashboard", null, "submitRequirementColumns"));
   }
 
+  private ImmutableList<MetadataInfo> getMetadata() {
+    ArrayList<MetadataInfo> metadataList = new ArrayList<>();
+    serverStateProviders.runEach(
+        serverStateProvider -> metadataList.addAll(serverStateProvider.getMetadata()));
+    return metadataList.stream()
+        .sorted(
+            Comparator.comparing((MetadataInfo metadata) -> metadata.name)
+                .thenComparing(
+                    (MetadataInfo metadata) -> metadata.value != null ? metadata.value : ""))
+        .collect(toImmutableList());
+  }
+
   @Nullable
   private static Boolean toBoolean(boolean v) {
     return v ? Boolean.TRUE : null;
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index 77af0f3..c76f0a4 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -77,6 +77,7 @@
     int tasksTotal = pending.size();
     int tasksStopping = 0;
     int tasksRunning = 0;
+    int tasksParked = 0;
     int tasksStarting = 0;
     int tasksReady = 0;
     int tasksSleeping = 0;
@@ -88,6 +89,9 @@
         case RUNNING:
           tasksRunning++;
           break;
+        case PARKED:
+          tasksParked++;
+          break;
         case STARTING:
           tasksStarting++;
           break;
@@ -108,6 +112,7 @@
     taskSummary.total = toInteger(tasksTotal);
     taskSummary.stopping = toInteger(tasksStopping);
     taskSummary.running = toInteger(tasksRunning);
+    taskSummary.parked = toInteger(tasksParked);
     taskSummary.starting = toInteger(tasksStarting);
     taskSummary.ready = toInteger(tasksReady);
     taskSummary.sleeping = toInteger(tasksSleeping);
@@ -245,6 +250,7 @@
     public Integer total;
     public Integer stopping;
     public Integer running;
+    public Integer parked;
     public Integer starting;
     public Integer ready;
     public Integer sleeping;
diff --git a/java/com/google/gerrit/server/restapi/config/IndexCollection.java b/java/com/google/gerrit/server/restapi/config/IndexCollection.java
index c5d295d..99a5718 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexCollection.java
@@ -16,8 +16,6 @@
 
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -32,8 +30,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.List;
-import java.util.Locale;
 
 @RequiresCapability(MAINTAIN_SERVER)
 @Singleton
@@ -54,25 +50,10 @@
 
   @Override
   public IndexResource parse(ConfigResource parent, IdString id) throws ResourceNotFoundException {
-    if (id.toString().toLowerCase(Locale.US).equals("all")) {
-      ImmutableList.Builder<com.google.gerrit.index.IndexCollection<?, ?, ?>> allIndexes =
-          ImmutableList.builder();
-      for (IndexDefinition<?, ?, ?> def : defs) {
-        allIndexes.add(def.getIndexCollection());
-      }
-      return new IndexResource(allIndexes.build());
-    }
-
-    List<String> segments = Splitter.on('~').splitToList(id.toString());
-    if (segments.size() < 1 || 2 < segments.size()) {
-      throw new ResourceNotFoundException(id);
-    }
-    String indexName = segments.get(0);
-    Integer version = segments.size() == 2 ? Integer.valueOf(segments.get(1)) : null;
-
+    String indexName = id.toString();
     for (IndexDefinition<?, ?, ?> def : defs) {
       if (def.getName().equals(indexName)) {
-        return new IndexResource(def.getIndexCollection(), version);
+        return new IndexResource(def);
       }
     }
     throw new ResourceNotFoundException("Unknown index requested: " + indexName);
diff --git a/java/com/google/gerrit/server/restapi/config/IndexInfo.java b/java/com/google/gerrit/server/restapi/config/IndexInfo.java
index 4a6bd3e53..9fba0034 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexInfo.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexDefinition;
 
 @AutoValue
 public abstract class IndexInfo {
@@ -27,32 +28,41 @@
       String name, IndexCollection<?, ?, ?> indexCollection) {
     ImmutableSortedMap.Builder<Integer, IndexVersionInfo> versions =
         ImmutableSortedMap.naturalOrder();
-    int searchIndexVersion = indexCollection.getSearchIndex().getSchema().getVersion();
+    Index<?, ?> searchIndex = indexCollection.getSearchIndex();
+    int searchIndexVersion = searchIndex.getSchema().getVersion();
     boolean searchIndexAdded = false;
     for (Index<?, ?> index : indexCollection.getWriteIndexes()) {
       boolean isSearchIndex = index.getSchema().getVersion() == searchIndexVersion;
-      versions.put(index.getSchema().getVersion(), IndexVersionInfo.create(true, isSearchIndex));
+      versions.put(
+          index.getSchema().getVersion(),
+          IndexVersionInfo.create(true, isSearchIndex, index.numDocs()));
       searchIndexAdded = searchIndexAdded || isSearchIndex;
     }
     if (!searchIndexAdded) {
-      versions.put(searchIndexVersion, IndexVersionInfo.create(false, true));
+      versions.put(searchIndexVersion, IndexVersionInfo.create(false, true, searchIndex.numDocs()));
     }
 
     return new AutoValue_IndexInfo(name, versions.build());
   }
 
+  public static IndexInfo fromIndexDefinition(IndexDefinition<?, ?, ?> def) {
+    return fromIndexCollection(def.getName(), def.getIndexCollection());
+  }
+
   public abstract String getName();
 
   public abstract ImmutableMap<Integer, IndexVersionInfo> getVersions();
 
   @AutoValue
   public abstract static class IndexVersionInfo {
-    static IndexVersionInfo create(boolean write, boolean search) {
-      return new AutoValue_IndexInfo_IndexVersionInfo(write, search);
+    static IndexVersionInfo create(boolean write, boolean search, int numDocs) {
+      return new AutoValue_IndexInfo_IndexVersionInfo(write, search, numDocs);
     }
 
     abstract boolean isWrite();
 
     abstract boolean isSearch();
+
+    abstract int numDocs();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/IndexVersionsCollection.java b/java/com/google/gerrit/server/restapi/config/IndexVersionsCollection.java
new file mode 100644
index 0000000..44c49f3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/IndexVersionsCollection.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.IndexResource;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@RequiresCapability(MAINTAIN_SERVER)
+@Singleton
+public class IndexVersionsCollection
+    implements ChildCollection<IndexResource, IndexVersionResource> {
+
+  private final DynamicMap<RestView<IndexVersionResource>> views;
+  private final Provider<ListIndexVersions> list;
+
+  @Inject
+  IndexVersionsCollection(
+      DynamicMap<RestView<IndexVersionResource>> views, Provider<ListIndexVersions> list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<IndexResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public IndexVersionResource parse(IndexResource parent, IdString id)
+      throws ResourceNotFoundException, Exception {
+    try {
+      int version = Integer.parseInt(id.get());
+      IndexDefinition<?, ?, ?> def = parent.getIndexDefinition();
+      IndexCollection<?, ?, ?> indexCollection = def.getIndexCollection();
+      Index<?, ?> index = indexCollection.getWriteIndex(version);
+      if (index == null) {
+        Index<?, ?> searchIndex = indexCollection.getSearchIndex();
+        if (searchIndex.getSchema().getVersion() == version) {
+          index = searchIndex;
+        }
+      }
+      if (index != null) {
+        return new IndexVersionResource(def, index);
+      }
+    } catch (NumberFormatException e) {
+      throw new ResourceNotFoundException("'" + id.get() + "' is not a number", e);
+    }
+
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public DynamicMap<RestView<IndexVersionResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListExperiments.java b/java/com/google/gerrit/server/restapi/config/ListExperiments.java
new file mode 100644
index 0000000..a41b917
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListExperiments.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.function.Function;
+import org.kohsuke.args4j.Option;
+
+/** List capabilities visible to the calling user. */
+public class ListExperiments implements RestReadView<ConfigResource> {
+  public static ImmutableList<String> getExperiments() {
+    return Arrays.stream(ExperimentFeaturesConstants.class.getDeclaredFields())
+        .filter(field -> field.getType().equals(String.class))
+        .map(
+            field -> {
+              try {
+                return (String) field.get(null);
+              } catch (IllegalAccessException e) {
+                return null;
+              }
+            })
+        .filter(Objects::nonNull)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private final PermissionBackend permissionBackend;
+  private final ExperimentFeatures experimentFeatures;
+  private final GetExperiment getExperiment;
+
+  private boolean enabledOnly;
+
+  @Option(name = "--enabled-only", usage = "only return enabled experiments")
+  public void setEnabledOnly(boolean enabledOnly) {
+    this.enabledOnly = enabledOnly;
+  }
+
+  @Inject
+  public ListExperiments(
+      PermissionBackend permissionBackend,
+      ExperimentFeatures experimentFeatures,
+      GetExperiment getExperiment) {
+    this.permissionBackend = permissionBackend;
+    this.experimentFeatures = experimentFeatures;
+    this.getExperiment = getExperiment;
+  }
+
+  @Override
+  public Response<ImmutableMap<String, ExperimentInfo>> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+    return Response.ok(
+        getExperiments().stream()
+            .filter(
+                experimentName ->
+                    !enabledOnly || experimentFeatures.isFeatureEnabled(experimentName))
+            .collect(toImmutableMap(Function.identity(), getExperiment::getExperimentInfo)));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListIndexVersions.java b/java/com/google/gerrit/server/restapi/config/ListIndexVersions.java
new file mode 100644
index 0000000..91bdae0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListIndexVersions.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.IndexResource;
+import java.util.Map;
+
+@RequiresCapability(MAINTAIN_SERVER)
+public class ListIndexVersions implements RestReadView<IndexResource> {
+
+  @Override
+  public Response<Map<Integer, IndexInfo.IndexVersionInfo>> apply(IndexResource rsrc)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    IndexInfo info = IndexInfo.fromIndexDefinition(rsrc.getIndexDefinition());
+    return Response.ok(info.getVersions());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListIndexes.java b/java/com/google/gerrit/server/restapi/config/ListIndexes.java
index 9710000..2e6664b 100644
--- a/java/com/google/gerrit/server/restapi/config/ListIndexes.java
+++ b/java/com/google/gerrit/server/restapi/config/ListIndexes.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -23,8 +24,6 @@
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
 import java.util.Collection;
-import java.util.Map;
-import java.util.TreeMap;
 
 @RequiresCapability(MAINTAIN_SERVER)
 public class ListIndexes implements RestReadView<ConfigResource> {
@@ -35,13 +34,12 @@
     this.defs = defs;
   }
 
-  private Map<String, IndexInfo> getIndexInfos() {
-    Map<String, IndexInfo> indexInfos = new TreeMap<>();
+  private ImmutableList<IndexInfo> getIndexInfos() {
+    ImmutableList.Builder<IndexInfo> indexInfos = ImmutableList.builder();
     for (IndexDefinition<?, ?, ?> def : defs) {
-      String name = def.getName();
-      indexInfos.put(name, IndexInfo.fromIndexCollection(name, def.getIndexCollection()));
+      indexInfos.add(IndexInfo.fromIndexDefinition(def));
     }
-    return indexInfos;
+    return indexInfos.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/config/ReindexIndexVersion.java b/java/com/google/gerrit/server/restapi/config/ReindexIndexVersion.java
new file mode 100644
index 0000000..21cd1c1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ReindexIndexVersion.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.index.IndexVersionReindexer;
+import com.google.gerrit.server.restapi.config.ReindexIndexVersion.Input;
+import com.google.inject.Inject;
+
+public class ReindexIndexVersion implements RestModifyView<IndexVersionResource, Input> {
+  public static class Input {
+    public boolean reuse;
+    public boolean notifyListeners;
+  }
+
+  private final IndexVersionReindexer indexVersionReindexer;
+
+  @Inject
+  ReindexIndexVersion(IndexVersionReindexer indexVersionReindexer) {
+    this.indexVersionReindexer = indexVersionReindexer;
+  }
+
+  @Override
+  public Response<?> apply(IndexVersionResource rsrc, Input input)
+      throws ResourceNotFoundException {
+    IndexDefinition<?, ?, ?> def = rsrc.getIndexDefinition();
+    int version = rsrc.getIndex().getSchema().getVersion();
+    @SuppressWarnings("unused")
+    var unused = indexVersionReindexer.reindex(def, version, input.reuse, input.notifyListeners);
+    return Response.accepted(
+        String.format("Index %s version %d submitted for reindexing", def.getName(), version));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/SnapshotIndex.java b/java/com/google/gerrit/server/restapi/config/SnapshotIndex.java
index 72af97f..c50367f 100644
--- a/java/com/google/gerrit/server/restapi/config/SnapshotIndex.java
+++ b/java/com/google/gerrit/server/restapi/config/SnapshotIndex.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.server.config.IndexResource;
 import com.google.gerrit.server.restapi.config.SnapshotIndex.Input;
 import com.google.inject.Singleton;
@@ -29,7 +30,6 @@
 import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
-import java.util.Collection;
 
 @RequiresCapability(MAINTAIN_SERVER)
 @Singleton
@@ -38,12 +38,12 @@
 
   @Override
   public Response<?> apply(IndexResource rsrc, Input input) throws IOException {
-    Collection<Index<?, ?>> indexes = rsrc.getIndexes();
     String id = input.id;
     if (id == null) {
       id = LocalDateTime.now(ZoneId.systemDefault()).format(formatter);
     }
-    for (Index<?, ?> index : indexes) {
+    IndexDefinition<?, ?, ?> def = rsrc.getIndexDefinition();
+    for (Index<?, ?> index : def.getIndexCollection().getWriteIndexes()) {
       try {
         @SuppressWarnings("unused")
         var unused = index.snapshot(id);
diff --git a/java/com/google/gerrit/server/restapi/config/SnapshotIndexVersion.java b/java/com/google/gerrit/server/restapi/config/SnapshotIndexVersion.java
new file mode 100644
index 0000000..9f66ab3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/SnapshotIndexVersion.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.restapi.config.SnapshotIndexVersion.Input;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@RequiresCapability(MAINTAIN_SERVER)
+@Singleton
+public class SnapshotIndexVersion implements RestModifyView<IndexVersionResource, Input> {
+  private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss");
+
+  @Override
+  public Response<?> apply(IndexVersionResource rsrc, Input input)
+      throws IOException, ResourceNotFoundException {
+    String id = input.id;
+    if (id == null) {
+      id = LocalDateTime.now(ZoneId.systemDefault()).format(formatter);
+    }
+    Index<?, ?> index = rsrc.getIndex();
+    var unused = index.snapshot(id);
+    SnapshotInfo info = new SnapshotInfo();
+    info.id = id;
+    return Response.ok(info);
+  }
+
+  public static class Input {
+    String id;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/SnapshotIndexes.java b/java/com/google/gerrit/server/restapi/config/SnapshotIndexes.java
new file mode 100644
index 0000000..6a2c5f8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/SnapshotIndexes.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.restapi.config.SnapshotIndexes.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Collection;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+@Singleton
+public class SnapshotIndexes implements RestModifyView<ConfigResource, Input> {
+  private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss");
+
+  public static class Input {
+    String id;
+  }
+
+  private final Collection<IndexDefinition<?, ?, ?>> defs;
+
+  @Inject
+  SnapshotIndexes(Collection<IndexDefinition<?, ?, ?>> defs) {
+    this.defs = defs;
+  }
+
+  @Override
+  public Response<?> apply(ConfigResource resource, Input input) throws IOException {
+    String id = input.id;
+    if (id == null) {
+      id = LocalDateTime.now(ZoneId.systemDefault()).format(formatter);
+    }
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      for (Index<?, ?> index : def.getIndexCollection().getWriteIndexes()) {
+        try {
+          @SuppressWarnings("unused")
+          var unused = index.snapshot(id);
+        } catch (FileAlreadyExistsException e) {
+          return Response.withStatusCode(SC_CONFLICT, "Snapshot with same ID already exists.");
+        }
+      }
+    }
+    SnapshotInfo info = new SnapshotInfo();
+    info.id = id;
+    return Response.ok(info);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index c6da92f..6067a66 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -78,6 +78,12 @@
     return Response.ok(getDirectMembers(group, resource.getControl()));
   }
 
+  protected boolean canSeeGroup(InternalGroup group) {
+    InternalGroupDescription internalGroup = new InternalGroupDescription(group);
+    GroupControl groupControl = groupControlFactory.controlFor(internalGroup);
+    return groupControl.canSeeGroup();
+  }
+
   public List<AccountInfo> getTransitiveMembers(AccountGroup.UUID groupUuid)
       throws PermissionBackendException {
     Optional<InternalGroup> group = groupCache.get(groupUuid);
diff --git a/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
new file mode 100644
index 0000000..b3ad599
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.AbstractBatchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Base class for a rest API batch update. */
+public abstract class AbstractPostCollection<
+        TId,
+        TResource extends RestResource,
+        TItemInput,
+        TBatchInput extends AbstractBatchInput<TItemInput>>
+    implements RestCollectionModifyView<ProjectResource, TResource, TBatchInput> {
+  private final Provider<CurrentUser> user;
+  private final RepoMetaDataUpdater updater;
+
+  public AbstractPostCollection(RepoMetaDataUpdater updater, Provider<CurrentUser> user) {
+    this.user = user;
+    this.updater = updater;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource rsrc, TBatchInput input)
+      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
+          ConfigInvalidException, BadRequestException, ResourceConflictException,
+          MethodNotAllowedException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (input == null) {
+      return Response.ok("");
+    }
+
+    try (var configUpdater =
+        updater.configUpdater(rsrc.getNameKey(), input.commitMessage, defaultCommitMessage())) {
+      ProjectConfig config = configUpdater.getConfig();
+      if (updateProjectConfig(config, input)) {
+        configUpdater.commitConfigUpdate();
+      }
+    }
+    return Response.ok("");
+  }
+
+  public boolean updateProjectConfig(ProjectConfig config, AbstractBatchInput<TItemInput> input)
+      throws UnprocessableEntityException, ResourceConflictException, BadRequestException {
+    boolean configChanged = false;
+    if (input.delete != null && !input.delete.isEmpty()) {
+      for (String name : input.delete) {
+        if (Strings.isNullOrEmpty(name)) {
+          throw new BadRequestException("The delete property contains null or empty name");
+        }
+        deleteItem(config, name.trim());
+      }
+      configChanged = true;
+    }
+    if (input.create != null && !input.create.isEmpty()) {
+      for (TItemInput labelInput : input.create) {
+        if (labelInput == null) {
+          throw new BadRequestException("The create property contains a null item");
+        }
+        createItem(config, labelInput);
+      }
+      configChanged = true;
+    }
+    if (input.update != null && !input.update.isEmpty()) {
+      for (var e : input.update.entrySet()) {
+        if (e.getKey() == null) {
+          throw new BadRequestException("The update property contains a null key");
+        }
+        if (e.getValue() == null) {
+          throw new BadRequestException("The update property contains a null value");
+        }
+        configChanged |= updateItem(config, e.getKey().trim(), e.getValue());
+      }
+    }
+    return configChanged;
+  }
+
+  /** Provides default commit message when user doesn't specify one in the input. */
+  public abstract String defaultCommitMessage();
+
+  protected abstract boolean updateItem(ProjectConfig config, String name, TItemInput resource)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+
+  protected abstract void createItem(ProjectConfig config, TItemInput resource)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+
+  protected abstract void deleteItem(ProjectConfig config, String name)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 338ff0d..3a50275 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -14,17 +14,9 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.AccessSection;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -33,89 +25,31 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
 public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
-  private final PermissionBackend permissionBackend;
-  private final Sequences seq;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final BatchUpdate.Factory updateFactory;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final SetAccessUtil setAccess;
-  private final ChangeJson.Factory jsonFactory;
-  private final ProjectCache projectCache;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  CreateAccessChange(
-      PermissionBackend permissionBackend,
-      ChangeInserter.Factory changeInserterFactory,
-      BatchUpdate.Factory updateFactory,
-      Sequences seq,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      SetAccessUtil accessUtil,
-      ChangeJson.Factory jsonFactory,
-      ProjectCache projectCache,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.permissionBackend = permissionBackend;
-    this.seq = seq;
-    this.changeInserterFactory = changeInserterFactory;
-    this.updateFactory = updateFactory;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
+  CreateAccessChange(SetAccessUtil accessUtil, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.setAccess = accessUtil;
-    this.jsonFactory = jsonFactory;
-    this.projectCache = projectCache;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws PermissionBackendException, AuthException, IOException, ConfigInvalidException,
-          InvalidNameException, UpdateException, RestApiException {
-    PermissionBackend.ForProject forProject =
-        permissionBackend.user(rsrc.getUser()).project(rsrc.getNameKey());
-    if (!check(forProject, ProjectPermission.READ_CONFIG)) {
-      throw new AuthException(RefNames.REFS_CONFIG + " not visible");
-    }
-    if (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
-      try {
-        forProject.ref(RefNames.REFS_CONFIG).check(RefPermission.CREATE_CHANGE);
-      } catch (AuthException denied) {
-        throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG, denied);
-      }
-    }
-    projectCache
-        .get(rsrc.getNameKey())
-        .orElseThrow(illegalState(rsrc.getNameKey()))
-        .checkStatePermitsWrite();
-
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
     ImmutableList<AccessSection> removals =
         setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
@@ -123,12 +57,10 @@
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
-
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-      ObjectId oldCommit = config.getRevision();
-      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
-
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.message, "Review access change")) {
+      ProjectConfig config = creator.getConfig();
       setAccess.validateChanges(config, removals, additions);
       setAccess.applyChanges(config, removals, additions);
       try {
@@ -141,63 +73,9 @@
       } catch (AuthException e) {
         throw new IllegalStateException(e);
       }
-
-      if (!Strings.isNullOrEmpty(input.message)) {
-        if (!input.message.endsWith("\n")) {
-          input.message += "\n";
-        }
-        md.setMessage(input.message);
-      } else {
-        md.setMessage("Review access change\n");
-      }
-
-      md.setInsertChangeId(true);
-      Change.Id changeId = Change.id(seq.nextChangeId());
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RevCommit commit =
-            config.commitToNewRef(
-                md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
-
-        if (commit.name().equals(oldCommitSha1)) {
-          throw new BadRequestException("no change");
-        }
-
-        try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
-            ObjectReader objReader = objInserter.newReader();
-            RevWalk rw = new RevWalk(objReader);
-            BatchUpdate bu =
-                updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
-          bu.setRepository(md.getRepository(), rw, objInserter);
-          ChangeInserter ins = newInserter(changeId, commit);
-          bu.insertChange(ins);
-          bu.execute();
-          return Response.created(jsonFactory.noOptions().format(ins.getChange()));
-        }
-      }
+      return creator.createChange();
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     }
   }
-
-  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
-  @SuppressWarnings("deprecation")
-  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
-    return changeInserterFactory
-        .create(changeId, commit, RefNames.REFS_CONFIG)
-        .setMessage(
-            // Same message as in ReceiveCommits.CreateRequest.
-            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
-        .setValidate(false)
-        .setUpdateRef(false);
-  }
-
-  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
-      throws PermissionBackendException {
-    try {
-      perm.check(p);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 7b15350..6bec417 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -23,23 +23,18 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelDefinitionJson;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
@@ -49,43 +44,22 @@
 @Singleton
 public class CreateLabel
     implements RestCollectionCreateView<ProjectResource, LabelResource, LabelDefinitionInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public CreateLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      ApprovalQueryBuilder approvalQueryBuilder) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.approvalQueryBuilder = approvalQueryBuilder;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<LabelDefinitionInfo> apply(
       ProjectResource rsrc, IdString id, LabelDefinitionInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
-          PermissionBackendException, IOException, ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+          PermissionBackendException, IOException, ConfigInvalidException,
+          MethodNotAllowedException {
     if (input == null) {
       input = new LabelDefinitionInput();
     }
@@ -93,22 +67,10 @@
     if (input.name != null && !input.name.equals(id.get())) {
       throw new BadRequestException("name in input must match name in URL");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      LabelType labelType = createLabel(config, id.get(), input);
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Update label");
-      }
-
-      config.commit(md);
-
-      projectCache.evictAndReindex(rsrc.getProjectState().getProject());
-
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(rsrc.getNameKey(), input.commitMessage, "Update label")) {
+      LabelType labelType = createLabel(configUpdater.getConfig(), id.get(), input);
+      configUpdater.commitConfigUpdate();
       return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
     }
   }
@@ -123,6 +85,7 @@
    * @throws BadRequestException if there was invalid data in the input
    * @throws ResourceConflictException if the label cannot be created due to a conflict
    */
+  @SuppressWarnings("deprecation")
   public LabelType createLabel(ProjectConfig config, String label, LabelDefinitionInput input)
       throws BadRequestException, ResourceConflictException {
     if (config.getLabelSections().containsKey(label)) {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 8be96b5..6d6d34e 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -45,10 +45,10 @@
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.project.LockManager;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectCreator;
 import com.google.gerrit.server.project.ProjectJson;
-import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
@@ -78,7 +78,7 @@
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
-  private final PluginItemContext<ProjectNameLockManager> lockManager;
+  private final PluginItemContext<LockManager> lockManager;
   private final ProjectCreator projectCreator;
 
   private final Config gerritConfig;
@@ -94,7 +94,7 @@
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      PluginItemContext<ProjectNameLockManager> lockManager,
+      PluginItemContext<LockManager> lockManager,
       @GerritServerConfig Config gerritConfig) {
     this.projectsCollection = projectsCollection;
     this.projectCreator = projectCreator;
@@ -167,7 +167,7 @@
       throw new BadRequestException(e.getMessage());
     }
 
-    Lock nameLock = lockManager.call(lockManager -> lockManager.getLock(args.getProject()));
+    Lock nameLock = lockManager.call(lockManager -> lockManager.getLock(args.getProject().get()));
     nameLock.lock();
     try {
       try {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
index a46211c..3fa4905 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -23,15 +23,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
@@ -39,7 +35,6 @@
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -50,42 +45,43 @@
 public class CreateSubmitRequirement
     implements RestCollectionCreateView<
         ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+  private final RepoMetaDataUpdater updater;
 
   @Inject
   public CreateSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+      RepoMetaDataUpdater updater) {
     this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+    this.updater = updater;
   }
 
   @Override
   public Response<SubmitRequirementInfo> apply(
       ProjectResource rsrc, IdString id, SubmitRequirementInput input)
-      throws AuthException, BadRequestException, IOException, PermissionBackendException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
+      throws AuthException, BadRequestException, IOException, PermissionBackendException,
+          MethodNotAllowedException {
+    String defaultMessage = String.format("Create Submit Requirement %s", id.get());
+    try (var configUpdater =
+        updater.configUpdater(
+            rsrc.getNameKey(),
+            /** message= */
+            null,
+            defaultMessage)) {
+      ProjectConfig config = configUpdater.getConfig();
+      SubmitRequirement submitRequirement = updateConfig(config, id, input);
+
+      configUpdater.commitConfigUpdate();
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
     }
+  }
 
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+  SubmitRequirement updateConfig(ProjectConfig config, IdString id, SubmitRequirementInput input)
+      throws ResourceConflictException, BadRequestException {
     if (input == null) {
       input = new SubmitRequirementInput();
     }
@@ -93,23 +89,7 @@
     if (input.name != null && !input.name.equals(id.get())) {
       throw new BadRequestException("name in input must match name in URL");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
-
-      md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
-      config.commit(md);
-
-      projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
-
-      return Response.created(SubmitRequirementJson.format(submitRequirement));
-    } catch (ConfigInvalidException e) {
-      throw new IOException("Failed to read project config", e);
-    } catch (ResourceConflictException e) {
-      throw new BadRequestException("Failed to create submit requirement", e);
-    }
+    return createSubmitRequirement(config, id.get(), input);
   }
 
   public SubmitRequirement createSubmitRequirement(
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 8a1927a..1c3ed5d 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -14,84 +14,50 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.extensions.common.InputWithCommitMessage;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteLabel implements RestModifyView<LabelResource, InputWithCommitMessage> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  public DeleteLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+  public DeleteLabel(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<?> apply(LabelResource rsrc, InputWithCommitMessage input)
       throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException,
-          ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+          ConfigInvalidException, BadRequestException, MethodNotAllowedException {
     if (input == null) {
       input = new InputWithCommitMessage();
     }
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), input.commitMessage, "Delete label")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (!deleteLabel(config, rsrc.getLabelType().getName())) {
         throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
       }
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Delete label");
-      }
-
-      config.commit(md);
+      configUpdater.commitConfigUpdate();
     }
 
-    projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
-
     return Response.none();
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
index 1be4a5f..64e2399 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -15,57 +15,30 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class DeleteSubmitRequirement implements RestModifyView<SubmitRequirementResource, Input> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  public DeleteSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+  public DeleteSubmitRequirement(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<?> apply(SubmitRequirementResource rsrc, Input input) throws Exception {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), null, "Delete submit requirement")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (!deleteSubmitRequirement(config, rsrc.getSubmitRequirement().name())) {
         // This code is unreachable because the exception is thrown when rsrc was parsed
@@ -75,12 +48,9 @@
                 IdString.fromDecoded(rsrc.getSubmitRequirement().name())));
       }
 
-      md.setMessage("Delete submit requirement");
-      config.commit(md);
+      configUpdater.commitConfigUpdate();
     }
 
-    projectCache.evict(rsrc.getProject().getProjectState().getProject().getNameKey());
-
     return Response.none();
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index e1a3c0c..d83605e 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
 import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
 import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_TAG_REF;
+import static com.google.gerrit.server.permissions.ProjectPermission.UPDATE_CONFIG_WITHOUT_CREATING_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.permissions.RefPermission.READ;
 import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
@@ -269,6 +270,8 @@
     info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
     info.canAddTags = toBoolean(perm.testOrFalse(CREATE_TAG_REF));
     info.configVisible = canReadConfig || canWriteConfig;
+    info.requireChangeForConfigUpdate =
+        toBoolean(!perm.testOrFalse(UPDATE_CONFIG_WITHOUT_CREATING_CHANGE));
 
     info.groups =
         groups.entrySet().stream()
diff --git a/java/com/google/gerrit/server/restapi/project/IndexChanges.java b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
index 6ad0005..532bd24 100644
--- a/java/com/google/gerrit/server/restapi/project/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.util.io.NullOutputStream;
@@ -42,18 +41,18 @@
 public class IndexChanges implements RestModifyView<ProjectResource, Input> {
 
   private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
-  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
+  private final AllChangesIndexer.Factory allChangesIndexerFactory;
   private final ChangeIndexer indexer;
   private final ListeningExecutorService executor;
 
   @Inject
   IndexChanges(
       MultiProgressMonitor.Factory multiProgressMonitorFactory,
-      Provider<AllChangesIndexer> allChangesIndexerProvider,
+      AllChangesIndexer.Factory allChangesIndexerFactory,
       ChangeIndexer indexer,
       @IndexExecutor(BATCH) ListeningExecutorService executor) {
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
-    this.allChangesIndexerProvider = allChangesIndexerProvider;
+    this.allChangesIndexerFactory = allChangesIndexerFactory;
     this.indexer = indexer;
     this.executor = executor;
   }
@@ -65,7 +64,7 @@
         multiProgressMonitorFactory
             .create(ByteStreams.nullOutputStream(), TaskKind.INDEXING, "Reindexing project")
             .beginSubTask("", MultiProgressMonitor.UNKNOWN);
-    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
+    AllChangesIndexer allChangesIndexer = allChangesIndexerFactory.create();
     allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
     // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
     // return value.
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java b/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java
index 33f0b58..b942ec8 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java
@@ -21,6 +21,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Iterables;
@@ -562,7 +563,18 @@
 
   private Stream<ProjectState> filter(PermissionBackend.WithUser perm) throws BadRequestException {
     return StreamSupport.stream(scan().spliterator(), false)
-        .map(projectCache::get)
+        .map(
+            key -> {
+              try {
+                return projectCache.get(key);
+              } catch (StorageException e) {
+                if (Throwables.getCausalChain(e).stream().anyMatch(IOException.class::isInstance)) {
+                  logger.atSevere().log(
+                      "Unable to load project %s : %s", key.get(), e.getCause().getMessage());
+                }
+                return Optional.<ProjectState>empty();
+              }
+            })
         .filter(Optional::isPresent)
         .map(Optional::get)
         .filter(p -> permissionCheck(p, perm));
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 83d29de..2132153 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.entities.RefNames.isConfigRef;
-import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -42,6 +42,7 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
@@ -58,6 +59,7 @@
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final WebLinks links;
+  private final TagSorter tagSorter;
 
   @Option(
       name = "--limit",
@@ -78,6 +80,14 @@
   }
 
   @Option(
+      name = "--descending",
+      aliases = {"-d"},
+      usage = "return the tags in descending order")
+  public void setDescendingOrder(boolean descendingOrder) {
+    this.descendingOrder = descendingOrder;
+  }
+
+  @Option(
       name = "--match",
       aliases = {"-m"},
       metaVar = "MATCH",
@@ -95,24 +105,37 @@
     this.matchRegex = matchRegex;
   }
 
+  @Option(name = "--sort-by", usage = "sort the tags")
+  private void setSortBy(ListTagSortOption sortBy) {
+    this.sortBy = sortBy;
+  }
+
   private int limit;
   private int start;
+  private boolean descendingOrder;
   private String matchSubstring;
   private String matchRegex;
+  private ListTagSortOption sortBy = ListTagSortOption.REF;
 
   @Inject
   public ListTags(
-      GitRepositoryManager repoManager, PermissionBackend permissionBackend, WebLinks webLinks) {
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      WebLinks webLinks,
+      TagSorter tagSorter) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.links = webLinks;
+    this.tagSorter = tagSorter;
   }
 
   public ListTags request(ListRefsRequest<TagInfo> request) {
     this.setLimit(request.getLimit());
     this.setStart(request.getStart());
+    this.setDescendingOrder(request.getDescendingOrder());
     this.setMatchSubstring(request.getSubstring());
     this.setMatchRegex(request.getRegex());
+    this.setSortBy(request.getSortBy());
     return this;
   }
 
@@ -136,7 +159,10 @@
       }
     }
 
-    tags.sort(comparing(t -> t.ref));
+    tagSorter.sort(sortBy, tags, descendingOrder);
+    if (descendingOrder) {
+      Collections.reverse(tags);
+    }
 
     return Response.ok(
         new RefFilter<>(Constants.R_TAGS, (TagInfo tag) -> tag.ref)
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 3616f4b..7f502ff 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -14,138 +14,76 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** REST endpoint that allows to add, update and delete label definitions in a batch. */
 @Singleton
 public class PostLabels
-    implements RestCollectionModifyView<ProjectResource, LabelResource, BatchLabelInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
+    extends AbstractPostCollection<String, LabelResource, LabelDefinitionInput, BatchLabelInput> {
   private final DeleteLabel deleteLabel;
   private final CreateLabel createLabel;
   private final SetLabel setLabel;
-  private final ProjectCache projectCache;
 
   @Inject
   public PostLabels(
       Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
       DeleteLabel deleteLabel,
       CreateLabel createLabel,
       SetLabel setLabel,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
+      RepoMetaDataUpdater updater) {
+    super(updater, user);
     this.deleteLabel = deleteLabel;
     this.createLabel = createLabel;
     this.setLabel = setLabel;
-    this.projectCache = projectCache;
   }
 
   @Override
-  public Response<?> apply(ProjectResource rsrc, BatchLabelInput input)
-      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
-          ConfigInvalidException, BadRequestException, ResourceConflictException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
+  public String defaultCommitMessage() {
+    return "Update labels";
+  }
+
+  @Override
+  protected boolean updateItem(ProjectConfig config, String name, LabelDefinitionInput resource)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException {
+    LabelType labelType = config.getLabelSections().get(name);
+    if (labelType == null) {
+      throw new UnprocessableEntityException(String.format("label %s not found", name));
+    }
+    if (resource.commitMessage != null) {
+      throw new BadRequestException("commit message on label definition input not supported");
     }
 
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
+    return setLabel.updateLabel(config, labelType, resource);
+  }
 
-    if (input == null) {
-      input = new BatchLabelInput();
+  @Override
+  protected void createItem(ProjectConfig config, LabelDefinitionInput labelInput)
+      throws BadRequestException, ResourceConflictException {
+    if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
+      throw new BadRequestException("label name is required for new label");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      boolean dirty = false;
-
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      if (input.delete != null && !input.delete.isEmpty()) {
-        for (String labelName : input.delete) {
-          if (!deleteLabel.deleteLabel(config, labelName.trim())) {
-            throw new UnprocessableEntityException(String.format("label %s not found", labelName));
-          }
-        }
-        dirty = true;
-      }
-
-      if (input.create != null && !input.create.isEmpty()) {
-        for (LabelDefinitionInput labelInput : input.create) {
-          if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
-            throw new BadRequestException("label name is required for new label");
-          }
-          if (labelInput.commitMessage != null) {
-            throw new BadRequestException("commit message on label definition input not supported");
-          }
-          @SuppressWarnings("unused")
-          var unused = createLabel.createLabel(config, labelInput.name.trim(), labelInput);
-        }
-        dirty = true;
-      }
-
-      if (input.update != null && !input.update.isEmpty()) {
-        for (Map.Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
-          LabelType labelType = config.getLabelSections().get(e.getKey().trim());
-          if (labelType == null) {
-            throw new UnprocessableEntityException(String.format("label %s not found", e.getKey()));
-          }
-          if (e.getValue().commitMessage != null) {
-            throw new BadRequestException("commit message on label definition input not supported");
-          }
-
-          if (setLabel.updateLabel(config, labelType, e.getValue())) {
-            dirty = true;
-          }
-        }
-      }
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Update labels");
-      }
-
-      if (dirty) {
-        config.commit(md);
-        projectCache.evictAndReindex(rsrc.getProjectState().getProject());
-      }
+    if (labelInput.commitMessage != null) {
+      throw new BadRequestException("commit message on label definition input not supported");
     }
+    @SuppressWarnings("unused")
+    var unused = createLabel.createLabel(config, labelInput.name.trim(), labelInput);
+  }
 
-    return Response.ok("");
+  @Override
+  protected void deleteItem(ProjectConfig config, String name) throws UnprocessableEntityException {
+    if (!deleteLabel.deleteLabel(config, name)) {
+      throw new UnprocessableEntityException(String.format("label %s not found", name));
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
new file mode 100644
index 0000000..7c0936f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostLabelsReview implements RestModifyView<ProjectResource, BatchLabelInput> {
+
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PostLabels postLabels;
+
+  @Inject
+  PostLabelsReview(RepoMetaDataUpdater repoMetaDataUpdater, PostLabels postLabels) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.postLabels = postLabels;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, BatchLabelInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review labels change")) {
+      ProjectConfig config = creator.getConfig();
+      var unused = postLabels.updateProjectConfig(config, input);
+      return creator.createChange();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
new file mode 100644
index 0000000..71080a5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Provider;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+@Singleton
+public class PostSubmitRequirements
+    extends AbstractPostCollection<
+        IdString, SubmitRequirementResource, SubmitRequirementInput, BatchSubmitRequirementInput> {
+  CreateSubmitRequirement createSubmitRequirement;
+  DeleteSubmitRequirement deleteSubmitRequirement;
+  UpdateSubmitRequirement updateSubmitRequirement;
+
+  @Inject
+  public PostSubmitRequirements(
+      RepoMetaDataUpdater updater,
+      Provider<CurrentUser> user,
+      CreateSubmitRequirement createSubmitRequirement,
+      DeleteSubmitRequirement deleteSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement) {
+    super(updater, user);
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.deleteSubmitRequirement = deleteSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+  }
+
+  @Override
+  public String defaultCommitMessage() {
+    return "Update Submit Requirements";
+  }
+
+  @Override
+  protected boolean updateItem(ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, UnprocessableEntityException {
+    // The name and input.name can be different - the item should be renamed.
+    if (config.getSubmitRequirementSections().remove(name) == null) {
+      throw new UnprocessableEntityException(
+          String.format("Submit requirement %s not found", name));
+    }
+    var unused = updateSubmitRequirement.updateSubmitRequirement(config, input.name, input);
+    return true;
+  }
+
+  @Override
+  protected void createItem(ProjectConfig config, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    var unused = createSubmitRequirement.createSubmitRequirement(config, input.name, input);
+  }
+
+  @Override
+  protected void deleteItem(ProjectConfig config, String name) throws UnprocessableEntityException {
+    if (!deleteSubmitRequirement.deleteSubmitRequirement(config, name)) {
+      throw new UnprocessableEntityException(
+          String.format("Submit requirement %s not found", name));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
new file mode 100644
index 0000000..82761e7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostSubmitRequirementsReview
+    implements RestModifyView<ProjectResource, BatchSubmitRequirementInput> {
+
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PostSubmitRequirements postSubmitRequirements;
+
+  @Inject
+  PostSubmitRequirementsReview(
+      RepoMetaDataUpdater repoMetaDataUpdater, PostSubmitRequirements postSubmitRequirements) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.postSubmitRequirements = postSubmitRequirements;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, BatchSubmitRequirementInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review submit requirements change")) {
+      ProjectConfig config = creator.getConfig();
+      var unused = postSubmitRequirements.updateProjectConfig(config, input);
+      // If config isn't updated, the createChange throws BadRequestException. We don't need
+      // to explicitly check the updateProjectConfig result here.
+      return creator.createChange();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index a7e7894..5c8bf3d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -84,6 +84,7 @@
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
+    put(PROJECT_KIND, "config:review").to(PutConfigReview.class);
 
     post(PROJECT_KIND, "create.change").to(CreateChange.class);
 
@@ -102,10 +103,11 @@
 
     child(PROJECT_KIND, "labels").to(LabelsCollection.class);
     create(LABEL_KIND).to(CreateLabel.class);
-    postOnCollection(LABEL_KIND).to(PostLabels.class);
     get(LABEL_KIND).to(GetLabel.class);
     put(LABEL_KIND).to(SetLabel.class);
     delete(LABEL_KIND).to(DeleteLabel.class);
+    postOnCollection(LABEL_KIND).to(PostLabels.class);
+    post(PROJECT_KIND, "labels:review").to(PostLabelsReview.class);
 
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
@@ -115,6 +117,8 @@
     put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
     get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
     delete(SUBMIT_REQUIREMENT_KIND).to(DeleteSubmitRequirement.class);
+    postOnCollection(SUBMIT_REQUIREMENT_KIND).to(PostSubmitRequirements.class);
+    post(PROJECT_KIND, "submit_requirements:review").to(PostSubmitRequirementsReview.class);
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     create(TAG_KIND).to(CreateTag.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 7bca46d..bb4b2e7 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -34,9 +34,10 @@
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -47,15 +48,14 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.BooleanProjectConfigTransformations;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -65,7 +65,6 @@
 import java.util.Map;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -76,8 +75,6 @@
       Pattern.compile("^[a-zA-Z0-9]+[a-zA-Z0-9-]*$");
 
   private final boolean serverEnableSignedPush;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
@@ -88,11 +85,11 @@
   private final PermissionBackend permissionBackend;
   private final ProjectConfig.Factory projectConfigFactory;
 
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+
   @Inject
   PutConfig(
       @EnableSignedPush boolean serverEnableSignedPush,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
@@ -101,10 +98,9 @@
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user,
       PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
+      ProjectConfig.Factory projectConfigFactory,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.serverEnableSignedPush = serverEnableSignedPush;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
@@ -114,6 +110,7 @@
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -127,72 +124,74 @@
   }
 
   public ConfigInfo apply(ProjectState projectState, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+      throws BadRequestException, ResourceConflictException, PermissionBackendException,
+          AuthException, MethodNotAllowedException {
     Project.NameKey projectName = projectState.getNameKey();
     if (input == null) {
       throw new BadRequestException("config is required");
     }
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.updateProject(
-          p -> {
-            p.setDescription(Strings.emptyToNull(input.description));
-            for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
-              InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
-              if (val != null) {
-                p.setBooleanConfig(cfg, val);
-              }
-            }
-            if (input.maxObjectSizeLimit != null) {
-              p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
-            }
-            if (input.submitType != null) {
-              p.setSubmitType(input.submitType);
-            }
-            if (input.state != null) {
-              p.setState(input.state);
-            }
-          });
-
-      if (input.pluginConfigValues != null) {
-        setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
-      }
-
-      if (input.commentLinks != null) {
-        updateCommentLinks(projectConfig, input.commentLinks);
-      }
-
-      md.setMessage("Modified project settings\n");
-      try {
-        projectConfig.commit(md);
-        projectCache.evictAndReindex(projectConfig.getProject());
-        md.getRepository().setGitwebDescription(projectConfig.getProject().getDescription());
-      } catch (IOException e) {
-        if (e.getCause() instanceof ConfigInvalidException) {
-          throw new ResourceConflictException(
-              "Cannot update " + projectName + ": " + e.getCause().getMessage());
-        }
-        logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
-        throw new ResourceConflictException("Cannot update " + projectName);
-      }
-
-      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
+    try (ConfigUpdater updater =
+        repoMetaDataUpdater.configUpdater(
+            projectName, input.commitMessage, "Modified project settings")) {
+      updateConfig(projectState, updater.getConfig(), input);
+      updater.commitConfigUpdate();
+      updater
+          .getRepository()
+          .setGitwebDescription(updater.getConfig().getProject().getDescription());
+      ProjectState newProjectState =
+          projectStateFactory.create(
+              projectConfigFactory.read(updater.getRepository(), projectName).getCacheable());
       return ConfigInfoCreator.constructInfo(
           serverEnableSignedPush,
-          state,
+          newProjectState,
           user.get(),
           pluginConfigEntries,
           cfgFactory,
           allProjects,
           uiActions,
           views);
-    } catch (RepositoryNotFoundException notFound) {
-      throw new ResourceNotFoundException(projectName.get(), notFound);
+
+    } catch (IOException e) {
+      if (e.getCause() instanceof ConfigInvalidException) {
+        throw new ResourceConflictException(
+            "Cannot update " + projectName + ": " + e.getCause().getMessage());
+      }
+      logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
+      throw new ResourceConflictException("Cannot update " + projectName);
     } catch (ConfigInvalidException err) {
       throw new ResourceConflictException("Cannot read project " + projectName, err);
-    } catch (IOException err) {
-      throw new ResourceConflictException("Cannot update project " + projectName, err);
+    }
+  }
+
+  public void updateConfig(
+      ProjectState projectState, ProjectConfig projectConfig, ConfigInput input)
+      throws BadRequestException {
+    projectConfig.updateProject(
+        p -> {
+          p.setDescription(Strings.emptyToNull(input.description));
+          for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+            InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
+            if (val != null) {
+              p.setBooleanConfig(cfg, val);
+            }
+          }
+          if (input.maxObjectSizeLimit != null) {
+            p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
+          }
+          if (input.submitType != null) {
+            p.setSubmitType(input.submitType);
+          }
+          if (input.state != null) {
+            p.setState(input.state);
+          }
+        });
+
+    if (input.pluginConfigValues != null) {
+      setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
+    }
+
+    if (input.commentLinks != null) {
+      updateCommentLinks(projectConfig, input.commentLinks);
     }
   }
 
@@ -222,7 +221,8 @@
             value = Joiner.on("\n").join(v.getValue().values);
           }
 
-          if (projectConfigEntry.getDefaultValue().equals(value)) {
+          String defaultValue = projectConfigEntry.getDefaultValue();
+          if (defaultValue != null && defaultValue.equals(value)) {
             // If the value is equal to the default, unset in case it existed.
             if (oldValue != null) {
               validateProjectConfigEntryIsEditable(
@@ -301,7 +301,7 @@
     }
   }
 
-  private void updateCommentLinks(
+  private static void updateCommentLinks(
       ProjectConfig projectConfig, Map<String, CommentLinkInput> input) {
     for (Map.Entry<String, CommentLinkInput> e : input.entrySet()) {
       String name = e.getKey();
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
new file mode 100644
index 0000000..5c51003
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutConfigReview implements RestModifyView<ProjectResource, ConfigInput> {
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final PutConfig putConfig;
+
+  @Inject
+  PutConfigReview(RepoMetaDataUpdater repoMetaDataUpdater, PutConfig putConfig) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.putConfig = putConfig;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, ConfigInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.commitMessage, "Review config change")) {
+      putConfig.updateConfig(rsrc.getProjectState(), creator.getConfig(), input);
+      return creator.createChange();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index ec42035..20f439c 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -14,24 +14,19 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -39,53 +34,30 @@
 
 @Singleton
 public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
-  private final ProjectCache cache;
-  private final Provider<MetaDataUpdate.Server> updateFactory;
-  private final PermissionBackend permissionBackend;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  PutDescription(
-      ProjectCache cache,
-      Provider<MetaDataUpdate.Server> updateFactory,
-      PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
-    this.permissionBackend = permissionBackend;
-    this.projectConfigFactory = projectConfigFactory;
+  PutDescription(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<String> apply(ProjectResource resource, DescriptionInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException,
-          PermissionBackendException {
+          PermissionBackendException, BadRequestException, MethodNotAllowedException {
     if (input == null) {
       input = new DescriptionInput(); // Delete would set description to null.
     }
 
-    IdentifiedUser user = resource.getUser().asIdentifiedUser();
-    permissionBackend
-        .user(user)
-        .project(resource.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
-    try (MetaDataUpdate md = updateFactory.get().create(resource.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            resource.getNameKey(), input.commitMessage, "Update description")) {
+      ProjectConfig config = configUpdater.getConfig();
       String desc = input.description;
       config.updateProject(p -> p.setDescription(Strings.emptyToNull(desc)));
 
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage), "Update description\n");
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(resource.getProjectState().getProject());
-      md.getRepository().setGitwebDescription(config.getProject().getDescription());
+      configUpdater.commitConfigUpdate();
+      configUpdater.getRepository().setGitwebDescription(config.getProject().getDescription());
 
       return Strings.isNullOrEmpty(config.getProject().getDescription())
           ? Response.none()
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
new file mode 100644
index 0000000..e8456a8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -0,0 +1,365 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.MustBeClosed;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.MetaDataUpdate.User;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Updates repo refs/meta/config content. */
+@Singleton
+public class RepoMetaDataUpdater {
+  private final Provider<User> metaDataUpdateFactory;
+  private final Provider<CurrentUser> user;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final Sequences seq;
+
+  private final BatchUpdate.Factory updateFactory;
+
+  private final PermissionBackend permissionBackend;
+  private final ChangeJson.Factory jsonFactory;
+
+  @Inject
+  RepoMetaDataUpdater(
+      Provider<User> metaDataUpdateFactory,
+      Provider<CurrentUser> user,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      ChangeInserter.Factory changeInserterFactory,
+      Sequences seq,
+      BatchUpdate.Factory updateFactory,
+      PermissionBackend permissionBackend,
+      ChangeJson.Factory jsonFactory) {
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.user = user;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.changeInserterFactory = changeInserterFactory;
+    this.seq = seq;
+    this.updateFactory = updateFactory;
+    this.permissionBackend = permissionBackend;
+    this.jsonFactory = jsonFactory;
+  }
+
+  /**
+   * Returns a creator for creating project config changes.
+   *
+   * <p>The method checks that user has required permissions.
+   *
+   * <p>Usage:
+   *
+   * <pre>{@code
+   * try(var changeCreator =
+   *  repoMetaDataUpdater.configChangeCreator(projectName, message, defaultMessage)) {
+   *    ProjectConfig config = changeCreator.getConfig();
+   *    // ... update project config
+   *    // Create change - if the createChange method is not called, all updates are ignored and no
+   *    // change is created.
+   *    Response<ChangeInfo> result = changeCreator.createChange();
+   *  }
+   * }</pre>
+   *
+   * @param projectName the name of the project whose config should be updated
+   * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+   *     empty) - the {@code defaultMessage} is used.
+   * @param defaultMessage the default commit message if the user doesn't provide one.
+   */
+  @MustBeClosed
+  public ConfigChangeCreator configChangeCreator(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws PermissionBackendException, AuthException, ResourceConflictException, IOException,
+          ConfigInvalidException {
+    message = validateMessage(message, defaultMessage);
+    PermissionBackend.ForProject forProject =
+        permissionBackend.user(user.get()).project(projectName);
+    if (!check(forProject, ProjectPermission.READ_CONFIG)) {
+      throw new AuthException(RefNames.REFS_CONFIG + " not visible");
+    }
+    if (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
+      try {
+        forProject.ref(RefNames.REFS_CONFIG).check(RefPermission.CREATE_CHANGE);
+      } catch (AuthException denied) {
+        throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG, denied);
+      }
+    }
+    projectCache.get(projectName).orElseThrow(illegalState(projectName)).checkStatePermitsWrite();
+    // The MetaDataUpdate instance gets closed in the ConfigChangeCreator.close() method.
+    MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+    try {
+      md.setInsertChangeId(true);
+      md.setMessage(message);
+      ProjectConfig config = projectConfigFactory.read(md);
+      return new ConfigChangeCreator(md, projectName, user.get(), config);
+    } catch (Throwable t) {
+      try (md) {
+        throw t;
+      }
+    }
+  }
+
+  /**
+   * Returns an updater for updating project config without review.
+   *
+   * <p>The method checks that user has required permissions and that user can update config without
+   * review.
+   *
+   * <p>When the update is saved (using the {@link ConfigUpdater#commitConfigUpdate} method), the
+   * project cache is updated automatically.
+   *
+   * <p>Usage:
+   *
+   * <pre>{@code
+   * try(var configUpdater =
+   *  repoMetaDataUpdater.configUpdater(projectName, message, defaultMessage)) {
+   *    ProjectConfig config = changeCreator.getConfig();
+   *    // ... update project config
+   *    // Save updated config - if the commitConfigUpdate method is not called, all updates are ignored.
+   *    configUpdater.commitConfigUpdate();
+   *  }
+   * }</pre>
+   *
+   * @param projectName the name of the project whose config should be updated
+   * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+   *     empty) - the {@code defaultMessage} is used.
+   * @param defaultMessage the default commit message if the user doesn't provide one.
+   */
+  @MustBeClosed
+  public ConfigUpdater configUpdater(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws AuthException, PermissionBackendException, ConfigInvalidException, IOException,
+          MethodNotAllowedException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    permissionBackend.user(user.get()).project(projectName).check(ProjectPermission.WRITE_CONFIG);
+    return configUpdaterWithoutPermissionsCheck(projectName, message, defaultMessage);
+  }
+
+  /**
+   * Returns an updater for updating project config without review and skips some permissions
+   * checks.
+   *
+   * <p>The method only checks that user can update config without review and doesn't do any other
+   * permissions checks. It should be used only when standard permissions checks from {@link
+   * #configUpdater} can't be used.
+   *
+   * <p>See {@link #configUpdater} for details.
+   */
+  @MustBeClosed
+  public ConfigUpdater configUpdaterWithoutPermissionsCheck(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws IOException, ConfigInvalidException, MethodNotAllowedException,
+          PermissionBackendException {
+    if (!permissionBackend
+        .currentUser()
+        .project(projectName)
+        .test(ProjectPermission.UPDATE_CONFIG_WITHOUT_CREATING_CHANGE)) {
+      throw new MethodNotAllowedException(
+          "Updating project config without review is disabled. Please create a change and send it "
+              + "for review. Some rest API methods have alternatives for creating required changes "
+              + "automatically - please check gerrit documentation.");
+    }
+    message = validateMessage(message, defaultMessage);
+    // The MetaDataUpdate instance gets closed in the ConfigUpdater.close() method.
+    MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+    try {
+      ProjectConfig config = projectConfigFactory.read(md);
+      md.setMessage(message);
+      return new ConfigUpdater(md, config);
+    } catch (Throwable t) {
+      try (md) {
+        throw t;
+      }
+    }
+  }
+
+  /**
+   * Updater for a project config without review.
+   *
+   * <p>See {@link #configUpdater} and {@link #configUpdaterWithoutPermissionsCheck} for details and
+   * usages.
+   */
+  public class ConfigUpdater implements AutoCloseable {
+    private final MetaDataUpdate md;
+    private final ProjectConfig config;
+
+    private ConfigUpdater(MetaDataUpdate md, ProjectConfig config) {
+      this.md = md;
+      this.config = config;
+    }
+
+    public ProjectConfig getConfig() {
+      return config;
+    }
+
+    public void commitConfigUpdate() throws IOException {
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+    }
+
+    public Repository getRepository() {
+      return md.getRepository();
+    }
+
+    @Override
+    public void close() {
+      md.close();
+    }
+  }
+
+  /**
+   * Creates a change for a project config update.
+   *
+   * <p>See {@link #createChange} for details and usages.
+   */
+  public class ConfigChangeCreator implements AutoCloseable {
+    private final MetaDataUpdate md;
+    private final String oldCommitSha1;
+    private final Project.NameKey projectName;
+    private final CurrentUser user;
+    private final ProjectConfig config;
+    private boolean changeCreated;
+
+    private ConfigChangeCreator(
+        MetaDataUpdate md, Project.NameKey projectName, CurrentUser user, ProjectConfig config) {
+      this.md = md;
+      this.config = config;
+      this.projectName = projectName;
+      this.user = user;
+      ObjectId oldCommit = config.getRevision();
+      oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
+    }
+
+    @Override
+    public void close() {
+      md.close();
+    }
+
+    public ProjectConfig getConfig() {
+      return config;
+    }
+
+    public Response<ChangeInfo> createChange()
+        throws IOException, UpdateException, RestApiException {
+      checkState(!changeCreated, "Change has been already created");
+      changeCreated = true;
+
+      Change.Id changeId = Change.id(seq.nextChangeId());
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RevCommit commit =
+            config.commitToNewRef(
+                md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+
+        if (commit.name().equals(oldCommitSha1)) {
+          throw new BadRequestException("no change");
+        }
+
+        try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+            ObjectReader objReader = objInserter.newReader();
+            RevWalk rw = new RevWalk(objReader);
+            BatchUpdate bu = updateFactory.create(projectName, user, TimeUtil.now())) {
+          bu.setRepository(md.getRepository(), rw, objInserter);
+          ChangeInserter ins = newInserter(changeId, commit);
+          bu.insertChange(ins);
+          bu.execute();
+          Change change = ins.getChange();
+          return Response.created(jsonFactory.noOptions().format(change));
+        }
+      }
+    }
+
+    // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+    @SuppressWarnings("deprecation")
+    private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+      return changeInserterFactory
+          .create(changeId, commit, RefNames.REFS_CONFIG)
+          .setMessage(
+              // Same message as in ReceiveCommits.CreateRequest.
+              ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+          .setValidate(false)
+          .setUpdateRef(false);
+    }
+  }
+
+  private String validateMessage(@Nullable String message, String defaultMessage) {
+    if (Strings.isNullOrEmpty(message)) {
+      message = defaultMessage;
+    } else {
+      message = message.trim();
+    }
+    checkArgument(!message.isBlank(), "The message must not be empty");
+    if (!message.endsWith("\n")) {
+      return message + "\n";
+    }
+    return message;
+  }
+
+  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
+      throws PermissionBackendException {
+    try {
+      perm.check(p);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index e4e4373..65851c0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.AccessSection;
@@ -32,13 +31,12 @@
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -49,53 +47,44 @@
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
   private final PermissionBackend permissionBackend;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final GetAccess getAccess;
-  private final ProjectCache projectCache;
   private final Provider<IdentifiedUser> identifiedUser;
   private final SetAccessUtil accessUtil;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
-  private final ProjectConfig.Factory projectConfigFactory;
 
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
       PermissionBackend permissionBackend,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser,
       SetAccessUtil accessUtil,
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
-      ProjectConfig.Factory projectConfigFactory) {
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.getAccess = getAccess;
-    this.projectCache = projectCache;
     this.identifiedUser = identifiedUser;
     this.accessUtil = accessUtil;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
-    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
   public Response<ProjectAccessInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
       throws Exception {
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-
     validateInput(input);
 
-    ProjectConfig config;
-
     ImmutableList<AccessSection> removals =
         accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
         accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      config = projectConfigFactory.read(md);
 
-      // Check that the user has the right permissions.
+    try (ConfigUpdater updater =
+        repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+            rsrc.getNameKey(), input.message, "Modify access rules")) {
+      ProjectConfig config = updater.getConfig();
       boolean checkedAdmin = false;
       for (AccessSection section : Iterables.concat(additions, removals)) {
         boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
@@ -123,17 +112,7 @@
           input.parent == null ? null : Project.nameKey(input.parent),
           !checkedAdmin);
 
-      if (!Strings.isNullOrEmpty(input.message)) {
-        if (!input.message.endsWith("\n")) {
-          input.message += "\n";
-        }
-        md.setMessage(input.message);
-      } else {
-        md.setMessage("Modify access rules\n");
-      }
-
-      config.commit(md);
-      projectCache.evictAndReindex(config.getProject());
+      updater.commitConfigUpdate();
       createGroupPermissionSyncer.syncIfNeeded();
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 853d7df..a46ee32 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
@@ -25,12 +24,8 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -41,30 +36,21 @@
 import org.kohsuke.args4j.Option;
 
 class SetDefaultDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
-  private final ProjectCache cache;
-  private final MetaDataUpdate.Server updateFactory;
   private final DashboardsCollection dashboards;
   private final Provider<GetDashboard> get;
-  private final PermissionBackend permissionBackend;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Option(name = "--inherited", usage = "set dashboard inherited by children")
   boolean inherited;
 
   @Inject
   SetDefaultDashboard(
-      ProjectCache cache,
-      MetaDataUpdate.Server updateFactory,
       DashboardsCollection dashboards,
       Provider<GetDashboard> get,
-      PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.dashboards = dashboards;
     this.get = get;
-    this.permissionBackend = permissionBackend;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -75,11 +61,6 @@
     }
     input.id = Strings.emptyToNull(input.id);
 
-    permissionBackend
-        .user(rsrc.getUser())
-        .project(rsrc.getProjectState().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
     DashboardResource target = null;
     if (input.id != null) {
       try {
@@ -93,29 +74,22 @@
         throw new ResourceConflictException(e.getMessage());
       }
     }
+    String defaultMessage =
+        input.id == null
+            ? "Removed default dashboard.\n"
+            : String.format("Changed default dashboard to %s.\n", input.id);
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProjectState().getNameKey(), input.commitMessage, defaultMessage)) {
+      ProjectConfig config = configUpdater.getConfig();
       String id = input.id;
       if (inherited) {
         config.updateProject(p -> p.setDefaultDashboard(id));
       } else {
         config.updateProject(p -> p.setLocalDefaultDashboard(id));
       }
-
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage),
-              input.id == null
-                  ? "Removed default dashboard.\n"
-                  : String.format("Changed default dashboard to %s.\n", input.id));
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(rsrc.getUser().asIdentifiedUser());
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(rsrc.getProjectState().getProject());
+      configUpdater.commitConfigUpdate();
 
       if (target != null) {
         Response<DashboardInfo> response = get.get().apply(target);
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index b5c9bba..0779fdd 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -20,22 +20,17 @@
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelDefinitionJson;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -43,63 +38,37 @@
 
 @Singleton
 public class SetLabel implements RestModifyView<LabelResource, LabelDefinitionInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public SetLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      ApprovalQueryBuilder approvalQueryBuilder) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.approvalQueryBuilder = approvalQueryBuilder;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<LabelDefinitionInfo> apply(LabelResource rsrc, LabelDefinitionInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
-          PermissionBackendException, IOException, ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+          PermissionBackendException, IOException, ConfigInvalidException,
+          MethodNotAllowedException {
     if (input == null) {
       input = new LabelDefinitionInput();
     }
 
     LabelType labelType = rsrc.getLabelType();
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), input.commitMessage, "Update label")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (updateLabel(config, labelType, input)) {
-        if (input.commitMessage != null) {
-          md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-        } else {
-          md.setMessage("Update label");
-        }
         String newName = Strings.nullToEmpty(input.name).trim();
         labelType =
             config.getLabelSections().get(newName.isEmpty() ? labelType.getName() : newName);
-
-        config.commit(md);
-        projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
+        configUpdater.commitConfigUpdate();
       }
     }
     return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
@@ -115,6 +84,7 @@
    * @throws BadRequestException if there was invalid data in the input
    * @throws ResourceConflictException if the update cannot be applied due to a conflict
    */
+  @SuppressWarnings("deprecation")
   public boolean updateLabel(ProjectConfig config, LabelType labelType, LabelDefinitionInput input)
       throws BadRequestException, ResourceConflictException {
     boolean dirty = false;
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index ef31dc5..fedd240 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -38,7 +39,6 @@
 import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -48,7 +48,6 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -60,61 +59,52 @@
     implements RestModifyView<ProjectResource, ParentInput>, GerritConfigListener {
   private final ProjectCache cache;
   private final PermissionBackend permissionBackend;
-  private final Provider<MetaDataUpdate.Server> updateFactory;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
   private volatile boolean allowProjectOwnersToChangeParent;
 
   @Inject
   SetParent(
       ProjectCache cache,
       PermissionBackend permissionBackend,
-      Provider<MetaDataUpdate.Server> updateFactory,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      ProjectConfig.Factory projectConfigFactory,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.cache = cache;
     this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
-    this.projectConfigFactory = projectConfigFactory;
     this.allowProjectOwnersToChangeParent =
         config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<String> apply(ProjectResource rsrc, ParentInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException,
-          BadRequestException {
+          BadRequestException, MethodNotAllowedException {
     return Response.ok(apply(rsrc, input, true));
   }
 
   public String apply(ProjectResource rsrc, ParentInput input, boolean checkIfAdmin)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
           UnprocessableEntityException, IOException, PermissionBackendException,
-          BadRequestException {
+          BadRequestException, MethodNotAllowedException {
     IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
     validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
-    try (MetaDataUpdate md = updateFactory.get().create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+            rsrc.getNameKey(),
+            input.commitMessage,
+            String.format("Changed parent to %s.\n", parentName))) {
+      ProjectConfig config = configUpdater.getConfig();
       config.updateProject(p -> p.setParent(parentName));
-
-      String msg = Strings.emptyToNull(input.commitMessage);
-      if (msg == null) {
-        msg = String.format("Changed parent to %s.\n", parentName);
-      } else if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(rsrc.getProjectState().getProject());
+      configUpdater.commitConfigUpdate();
 
       Project.NameKey parent = config.getProject().getParent(allProjects);
       requireNonNull(parent);
diff --git a/java/com/google/gerrit/server/restapi/project/TagSorter.java b/java/com/google/gerrit/server/restapi/project/TagSorter.java
new file mode 100644
index 0000000..4776ce1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/TagSorter.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static java.util.Comparator.comparing;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.List;
+
+public class TagSorter {
+  @Inject
+  public TagSorter() {}
+
+  /** Sort the tags by the given sort option, in place */
+  public void sort(ListTagSortOption sortBy, List<TagInfo> tags, boolean descendingOrder) {
+    switch (sortBy) {
+      case CREATION_TIME:
+        Comparator<Timestamp> nullsComparator =
+            descendingOrder
+                ? Comparator.nullsFirst(Comparator.naturalOrder())
+                : Comparator.nullsLast(Comparator.naturalOrder());
+        tags.sort(comparing(t -> t.created, nullsComparator));
+        break;
+      case REF:
+      default:
+        tags.sort(comparing(t -> t.ref));
+        break;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
index 3e1104e..2f264b5 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -22,21 +22,16 @@
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementJson;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -48,42 +43,22 @@
 @Singleton
 public class UpdateSubmitRequirement
     implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public UpdateSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<SubmitRequirementInfo> apply(
       SubmitRequirementResource rsrc, SubmitRequirementInput input)
-      throws AuthException, BadRequestException, PermissionBackendException, IOException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+      throws AuthException, BadRequestException, PermissionBackendException, IOException,
+          MethodNotAllowedException {
     if (input == null) {
       input = new SubmitRequirementInput();
     }
@@ -92,16 +67,17 @@
       throw new BadRequestException("name in input must match name in URL");
     }
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(),
+            null,
+            String.format("Update Submit Requirement %s", rsrc.getSubmitRequirement().name()))) {
+      ProjectConfig config = configUpdater.getConfig();
 
       SubmitRequirement submitRequirement =
-          createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+          updateSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
 
-      md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
-      config.commit(md);
-
-      projectCache.evict(rsrc.getProject().getNameKey());
+      configUpdater.commitConfigUpdate();
 
       return Response.created(SubmitRequirementJson.format(submitRequirement));
     } catch (ConfigInvalidException e) {
@@ -109,7 +85,7 @@
     }
   }
 
-  public SubmitRequirement createSubmitRequirement(
+  public SubmitRequirement updateSubmitRequirement(
       ProjectConfig config, String name, SubmitRequirementInput input) throws BadRequestException {
     validateSRName(name);
     if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
diff --git a/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
index a94fb6e..a568371 100644
--- a/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
+++ b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
@@ -20,22 +20,29 @@
 
 /** Provides prolog-related operations to different callers. */
 public interface PrologSubmitRuleUtil {
+  /** Returns true if prolog rules are enabled for the project. */
+  boolean isProjectRulesEnabled();
 
   /**
    * Returns the submit-type of a change depending on the change data and the definition of the
    * prolog rules file.
+   *
+   * <p>Must only be called when Prolog rules are enabled on the Gerrit server.
    */
   SubmitTypeRecord getSubmitType(ChangeData cd);
 
   /**
    * Returns the submit-type of a change depending on the change data and the definition of the
    * prolog rules file.
+   *
+   * <p>Must only be called when Prolog rules are enabled on the Gerrit server.
    */
   SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters);
 
-  /** Evaluates a submit rule. */
+  /**
+   * Evaluates a submit rule.
+   *
+   * <p>Must only be called when Prolog rules are enabled on the Gerrit server.
+   */
   SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters);
-
-  /** Returns true if prolog rules are enabled for the project. */
-  boolean isProjectRulesEnabled();
 }
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologRule.java b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
index 13814bb..c560fd2 100644
--- a/java/com/google/gerrit/server/rules/prolog/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
@@ -30,15 +30,22 @@
 class PrologRule implements SubmitRule {
   private final PrologRuleEvaluator.Factory factory;
   private final ProjectCache projectCache;
+  private final boolean isProjectRulesEnabled;
 
   @Inject
-  private PrologRule(PrologRuleEvaluator.Factory factory, ProjectCache projectCache) {
+  private PrologRule(
+      PrologRuleEvaluator.Factory factory, ProjectCache projectCache, RulesCache rulesCache) {
     this.factory = factory;
     this.projectCache = projectCache;
+    this.isProjectRulesEnabled = rulesCache.isProjectRulesEnabled();
   }
 
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
+    if (!isProjectRulesEnabled) {
+      return Optional.empty();
+    }
+
     ProjectState projectState =
         projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
     // We only want to run the Prolog engine if we have at least one rules.pl file to use.
@@ -49,15 +56,11 @@
     return Optional.of(evaluate(cd, PrologOptions.defaultOptions()));
   }
 
-  public SubmitRecord evaluate(ChangeData cd, PrologOptions opts) {
+  SubmitRecord evaluate(ChangeData cd, PrologOptions opts) {
     return getEvaluator(cd, opts).evaluate();
   }
 
-  public SubmitTypeRecord getSubmitType(ChangeData cd) {
-    return getSubmitType(cd, PrologOptions.defaultOptions());
-  }
-
-  public SubmitTypeRecord getSubmitType(ChangeData cd, PrologOptions opts) {
+  SubmitTypeRecord getSubmitType(ChangeData cd, PrologOptions opts) {
     return getEvaluator(cd, opts).getSubmitType();
   }
 
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
index 3d017e2..6be71f8 100644
--- a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.rules.prolog;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -25,7 +27,6 @@
 @Singleton
 public class PrologSubmitRuleUtilImpl implements PrologSubmitRuleUtil {
   private final PrologRule prologRule;
-
   private final RulesCache rulesCache;
 
   @Inject
@@ -35,22 +36,25 @@
   }
 
   @Override
+  public boolean isProjectRulesEnabled() {
+    return rulesCache.isProjectRulesEnabled();
+  }
+
+  @Override
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
-    return prologRule.getSubmitType(cd);
+    checkState(isProjectRulesEnabled(), "prolog rules disabled");
+    return prologRule.getSubmitType(cd, PrologOptions.defaultOptions());
   }
 
   @Override
   public SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    checkState(isProjectRulesEnabled(), "prolog rules disabled");
     return prologRule.getSubmitType(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
   }
 
   @Override
   public SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    checkState(isProjectRulesEnabled(), "prolog rules disabled");
     return prologRule.evaluate(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
   }
-
-  @Override
-  public boolean isProjectRulesEnabled() {
-    return rulesCache.isProjectRulesEnabled();
-  }
 }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 123a873..55ec9b0 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.block;
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.server.schema.AclUtil.rule;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
@@ -33,6 +34,8 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -44,6 +47,7 @@
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -131,11 +135,16 @@
       // init labels.
       input.codeReviewLabel().ifPresent(codeReviewLabel -> config.upsertLabelType(codeReviewLabel));
 
+      // init access sections.
       if (input.initDefaultAcls()) {
-        // init access sections.
         initDefaultAcls(config, input);
       }
 
+      // init submit requirement sections.
+      if (input.initDefaultSubmitRequirements()) {
+        initDefaultSubmitRequirements(config);
+      }
+
       // commit all the above configs as a commit in "refs/meta/config" branch of the All-Projects.
       config.commitToNewRef(md, RefNames.REFS_CONFIG);
 
@@ -155,7 +164,10 @@
 
     config.upsertAccessSection(
         AccessSection.HEADS,
-        heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
+        heads -> {
+          initDefaultAclsForAnonymousUsers(heads, config);
+          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+        });
 
     config.upsertAccessSection(
         AccessSection.GLOBAL_CAPABILITIES,
@@ -163,42 +175,64 @@
             input
                 .serviceUsersGroup()
                 .ifPresent(
-                    batchUsersGroup ->
-                        initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup)));
+                    serviceUsersGroup ->
+                        initDefaultAclsForServiceUsers(capabilities, config, serviceUsersGroup)));
+
+    input
+        .blockedUsersGroup()
+        .ifPresent(blockedUsersGrouo -> initDefaultAclsForBlockedUsers(config, blockedUsersGrouo));
 
     input
         .administratorsGroup()
         .ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
   }
 
-  private void initDefaultAclsForRegisteredUsers(
-      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
-    config.upsertAccessSection(
-        "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
+  private void initDefaultSubmitRequirements(ProjectConfig config) {
+    config.upsertSubmitRequirement(
+        SubmitRequirement.builder()
+            .setName("No-Unresolved-Comments")
+            .setDescription(
+                Optional.of("Changes that have unresolved comments are not submittable."))
+            .setApplicabilityExpression(SubmitRequirementExpression.of("has:unresolved"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  private void initDefaultAclsForAnonymousUsers(AccessSection.Builder heads, ProjectConfig config) {
+    grant(config, heads, Permission.READ, anonymous);
 
     config.upsertAccessSection(
         "refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
+  }
 
+  private void initDefaultAclsForRegisteredUsers(
+      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-    grant(config, heads, Permission.READ, anonymous);
-    grant(config, heads, Permission.REVERT, registered);
 
     config.upsertAccessSection(
-        "refs/for/" + AccessSection.ALL,
-        magic -> {
-          grant(config, magic, Permission.PUSH, registered);
-          grant(config, magic, Permission.PUSH_MERGE, registered);
+        "refs/for/*",
+        refsFor -> {
+          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+          grant(config, refsFor, Permission.PUSH, registered);
+          grant(config, refsFor, Permission.PUSH_MERGE, registered);
         });
   }
 
-  private void initDefaultAclsForBatchUsers(
-      AccessSection.Builder capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
+  private void initDefaultAclsForServiceUsers(
+      AccessSection.Builder capabilities, ProjectConfig config, GroupReference serviceUsersGroup) {
     Permission.Builder priority = capabilities.upsertPermission(GlobalCapability.PRIORITY);
-    priority.add(rule(config, batchUsersGroup).setAction(Action.BATCH));
+    priority.add(rule(config, serviceUsersGroup).setAction(Action.BATCH));
 
     Permission.Builder stream = capabilities.upsertPermission(GlobalCapability.STREAM_EVENTS);
-    stream.add(rule(config, batchUsersGroup));
+    stream.add(rule(config, serviceUsersGroup));
+  }
+
+  private void initDefaultAclsForBlockedUsers(
+      ProjectConfig config, GroupReference blockedUsersGroup) {
+    config.upsertAccessSection(
+        AccessSection.ALL, all -> block(config, all, Permission.READ, blockedUsersGroup));
   }
 
   private void initDefaultAclsForAdmins(
@@ -216,10 +250,10 @@
         heads -> {
           grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
           grant(config, heads, Permission.CREATE, adminsGroup, owners);
-          grant(config, heads, Permission.PUSH, adminsGroup, owners);
           grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
           grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
           grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+          grant(config, heads, Permission.REVERT, adminsGroup, owners);
         });
 
     config.upsertAccessSection(
@@ -237,7 +271,6 @@
           grant(config, meta, Permission.READ, adminsGroup, owners);
           grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
           grant(config, meta, Permission.CREATE, adminsGroup, owners);
-          grant(config, meta, Permission.PUSH, adminsGroup, owners);
           grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
         });
   }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 8db5b1a..f692691 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -69,6 +69,9 @@
   /** The group which gets stream-events permission granted and appropriate properties set. */
   public abstract Optional<GroupReference> serviceUsersGroup();
 
+  /** The group for which read access gets blocked. */
+  public abstract Optional<GroupReference> blockedUsersGroup();
+
   /** The commit message used when commit the project config change. */
   public abstract Optional<String> commitMessage();
 
@@ -89,6 +92,9 @@
   /** Whether initializing default access sections in All-Projects. */
   public abstract boolean initDefaultAcls();
 
+  /** Whether default submit requirements should be initialized in All-Projects. */
+  public abstract boolean initDefaultSubmitRequirements();
+
   public abstract Builder toBuilder();
 
   public static Builder builder() {
@@ -96,7 +102,8 @@
         new AutoValue_AllProjectsInput.Builder()
             .codeReviewLabel(getDefaultCodeReviewLabel())
             .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
-            .initDefaultAcls(true);
+            .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true);
     DEFAULT_BOOLEAN_PROJECT_CONFIGS.forEach(builder::addBooleanProjectConfig);
 
     return builder;
@@ -110,7 +117,9 @@
   public abstract static class Builder {
     public abstract Builder administratorsGroup(GroupReference adminGroup);
 
-    public abstract Builder serviceUsersGroup(GroupReference serviceGroup);
+    public abstract Builder serviceUsersGroup(GroupReference serviceUsersGroup);
+
+    public abstract Builder blockedUsersGroup(GroupReference blockedUsersGroup);
 
     public abstract Builder commitMessage(String commitMessage);
 
@@ -135,6 +144,8 @@
     @UsedAt(UsedAt.Project.GOOGLE)
     public abstract Builder initDefaultAcls(boolean initDefaultACLs);
 
+    public abstract Builder initDefaultSubmitRequirements(boolean initDefaultSubmitRequirements);
+
     public abstract AllProjectsInput build();
   }
 }
diff --git a/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
index d993c4a..9dca2d9 100644
--- a/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
@@ -28,9 +28,7 @@
 @Singleton
 public class CloudSpannerAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
 
-  private static final int ERR_DUP_KEY = 1022;
-  private static final int ERR_DUP_ENTRY = 1062;
-  private static final int ERR_DUP_UNIQUE = 1169;
+  private static final int ERR_DUP_KEY = 6;
 
   @Inject
   CloudSpannerAccountPatchReviewStore(
@@ -44,8 +42,6 @@
   public StorageException convertError(String op, SQLException err) {
     switch (err.getErrorCode()) {
       case ERR_DUP_KEY:
-      case ERR_DUP_ENTRY:
-      case ERR_DUP_UNIQUE:
         return new DuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
 
       default:
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
index 46a6857..927d3fd1 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -127,8 +127,7 @@
    *     parsed
    */
   public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
-    ProjectLevelConfig.Bare projectConfig =
-        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     try (Repository repo = repoManager.openRepository(projectName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
       boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index 2ca79342..d8da13d 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -130,8 +130,7 @@
       ui.message(String.format("Skipping project %s because it has prolog rules", project));
       return Status.HAS_PROLOG;
     }
-    ProjectLevelConfig.Bare projectConfig =
-        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     boolean migrationPerformed = false;
     try (Repository repo = repoManager.openRepository(project);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
@@ -275,7 +274,7 @@
     cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
   }
 
-  private void commit(ProjectLevelConfig.Bare projectConfig, MetaDataUpdate md) throws IOException {
+  private void commit(VersionedConfigFile projectConfig, MetaDataUpdate md) throws IOException {
     md.getCommitBuilder().setAuthor(serverUser);
     md.getCommitBuilder().setCommitter(serverUser);
     md.setMessage(COMMIT_MSG);
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 56c6fa8..fcd2264 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -54,6 +54,8 @@
 // confusing and could stand to be reworked. Another smell is that this is an interface only for
 // testing purposes.
 public class SchemaCreatorImpl implements SchemaCreator {
+  public static final String BLOCKED_USERS = "Blocked Users";
+
   private final GitRepositoryManager repoManager;
   private final AllProjectsCreator allProjectsCreator;
   private final AllUsersCreator allUsersCreator;
@@ -92,11 +94,13 @@
     try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
       GroupReference admins = createGroupReference("Administrators");
       GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+      GroupReference blockedUsers = createGroupReference(BLOCKED_USERS);
 
       AllProjectsInput allProjectsInput =
           AllProjectsInput.builder()
               .administratorsGroup(admins)
               .serviceUsersGroup(serviceUsers)
+              .blockedUsersGroup(blockedUsers)
               .build();
       allProjectsCreator.create(allProjectsInput);
       // We have to create the All-Users repository before we can use it to store the groups in it.
@@ -104,7 +108,8 @@
 
       try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
         createAdminsGroup(allUsersRepo, admins);
-        createBatchUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+        createServiceUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+        createBlockedUsersGroup(allUsersRepo, blockedUsers, admins.getUUID());
       }
     }
   }
@@ -127,7 +132,7 @@
     createGroup(allUsersRepo, groupCreation, groupDelta);
   }
 
-  private void createBatchUsersGroup(
+  private void createServiceUsersGroup(
       Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
       throws IOException, ConfigInvalidException {
     InternalGroupCreation groupCreation = getGroupCreation(groupReference);
@@ -135,6 +140,20 @@
         GroupDelta.builder()
             .setDescription("Users who perform batch actions on Gerrit")
             .setOwnerGroupUUID(adminsGroupUuid)
+            .setVisibleToAll(true)
+            .build();
+
+    createGroup(allUsersRepo, groupCreation, groupDelta);
+  }
+
+  private void createBlockedUsersGroup(
+      Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
+      throws IOException, ConfigInvalidException {
+    InternalGroupCreation groupCreation = getGroupCreation(groupReference);
+    GroupDelta groupDelta =
+        GroupDelta.builder()
+            .setDescription("Blocked users. Add spammers to this group.")
+            .setOwnerGroupUUID(adminsGroupUuid)
             .build();
 
     createGroup(allUsersRepo, groupCreation, groupDelta);
diff --git a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java b/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
deleted file mode 100644
index 468c26b..0000000
--- a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-
-/** Preferences for user accounts during schema migrations. */
-class VersionedAccountPreferences extends VersionedMetaData {
-  static final String PREFERENCES = "preferences.config";
-
-  static VersionedAccountPreferences forUser(Account.Id id) {
-    return new VersionedAccountPreferences(RefNames.refsUsers(id));
-  }
-
-  static VersionedAccountPreferences forDefault() {
-    return new VersionedAccountPreferences(RefNames.REFS_USERS_DEFAULT);
-  }
-
-  private final String ref;
-  private Config cfg;
-
-  protected VersionedAccountPreferences(String ref) {
-    this.ref = ref;
-  }
-
-  @Override
-  protected String getRefName() {
-    return ref;
-  }
-
-  Config getConfig() {
-    return cfg;
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    cfg = readConfig(PREFERENCES);
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    if (Strings.isNullOrEmpty(commit.getMessage())) {
-      commit.setMessage("Updated preferences\n");
-    }
-    saveConfig(PREFERENCES, cfg);
-    return true;
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 7243bdf..650c425 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,15 @@
       ImmutableList.of(
           "[access \"refs/*\"]",
           "  read = group Administrators",
+          "  read = block group Blocked Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
-          "[access \"refs/for/refs/*\"]",
           "  push = group Registered Users",
           "  pushMerge = group Registered Users",
           "[access \"refs/heads/*\"]",
           "  read = group Anonymous Users",
-          "  revert = group Registered Users",
+          "  revert = group Administrators",
+          "  revert = group Project Owners",
           "  create = group Administrators",
           "  create = group Project Owners",
           "  editTopicName = +force group Administrators",
@@ -72,8 +73,6 @@
           "  label-Code-Review = -2..+2 group Administrators",
           "  label-Code-Review = -2..+2 group Project Owners",
           "  label-Code-Review = -1..+1 group Registered Users",
-          "  push = group Administrators",
-          "  push = group Project Owners",
           "  submit = group Administrators",
           "  submit = group Project Owners",
           "[access \"refs/meta/config\"]",
@@ -82,8 +81,6 @@
           "  create = group Project Owners",
           "  label-Code-Review = -2..+2 group Administrators",
           "  label-Code-Review = -2..+2 group Project Owners",
-          "  push = group Administrators",
-          "  push = group Project Owners",
           "  read = group Administrators",
           "  read = group Project Owners",
           "  submit = group Administrators",
@@ -108,6 +105,13 @@
           "  value = 0 No score",
           "  value = +1 Looks good to me, but someone else must approve",
           "  value = +2 Looks good to me, approved");
+  private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION =
+      ImmutableList.of(
+          "[submit-requirement \"No-Unresolved-Comments\"]",
+          "  description = Changes that have unresolved comments are not submittable.",
+          "  applicableIf = has:unresolved",
+          "  submittableIf = -has:unresolved",
+          "  canOverrideInChildProjects = false");
 
   public static String getDefaultAllProjectsWithAllDefaultSections() {
     return Streams.stream(
@@ -117,7 +121,8 @@
                 DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
                 DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
                 DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
-                DEFAULT_ALL_PROJECTS_LABEL_SECTION))
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
         .collect(Collectors.joining("\n"));
   }
 
@@ -127,6 +132,19 @@
                 DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
                 DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
                 DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
+        .collect(Collectors.joining("\n"));
+  }
+
+  public static String getAllProjectsWithoutDefaultSubmitRequirements() {
+    return Streams.stream(
+            Iterables.concat(
+                DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+                DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+                DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
                 DEFAULT_ALL_PROJECTS_LABEL_SECTION))
         .collect(Collectors.joining("\n"));
   }
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index 7fe5e69..a213f28 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -84,9 +84,11 @@
     private PatchSet.Id psId;
     private CodeReviewCommit newCommit;
     private PatchSetInfo patchSetInfo;
+    private final boolean useDiff3;
 
     private CherryPickOneOp(CodeReviewCommit toMerge) {
       super(CherryPick.this.args, toMerge);
+      this.useDiff3 = args.cfg.getBoolean("change", null, "diff3ConflictView", false);
     }
 
     @Override
@@ -119,7 +121,8 @@
                 args.rw,
                 0,
                 false,
-                false);
+                false,
+                useDiff3);
       } catch (MergeConflictException mce) {
         // Keep going in the case of a single merge failure; the goal is to
         // cherry-pick as many commits as possible.
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index ce26552..4bd6f3d 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -29,12 +29,14 @@
 import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
+import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -49,7 +51,8 @@
         IdentifiedUser submitter,
         NotifyResolver.Result notify,
         RepoView repoView,
-        String stickyApprovalDiff);
+        String stickyApprovalDiff,
+        List<FileDiffOutput> modifiedFiles);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -63,6 +66,7 @@
   private final NotifyResolver.Result notify;
   private final RepoView repoView;
   private final String stickyApprovalDiff;
+  private final List<FileDiffOutput> modifiedFiles;
 
   @Inject
   EmailMerge(
@@ -75,7 +79,8 @@
       @Assisted @Nullable IdentifiedUser submitter,
       @Assisted NotifyResolver.Result notify,
       @Assisted RepoView repoView,
-      @Assisted String stickyApprovalDiff) {
+      @Assisted String stickyApprovalDiff,
+      @Assisted List<FileDiffOutput> modifiedFiles) {
     this.sendEmailsExecutor = executor;
     this.emailFactories = emailFactories;
     this.requestContext = requestContext;
@@ -86,6 +91,7 @@
     this.notify = notify;
     this.repoView = repoView;
     this.stickyApprovalDiff = stickyApprovalDiff;
+    this.modifiedFiles = modifiedFiles;
   }
 
   void sendAsync() {
@@ -102,7 +108,7 @@
               project,
               change.getId(),
               emailFactories.createMergedChangeEmail(
-                  Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff))));
+                  Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)), modifiedFiles));
       OutgoingEmail outgoingEmail = emailFactories.createOutgoingEmail(CHANGE_MERGED, changeEmail);
       if (submitter != null) {
         outgoingEmail.setFrom(submitter.getAccountId());
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
index b56d9ef..ed07f2f 100644
--- a/java/com/google/gerrit/server/submit/MergeMetrics.java
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -54,32 +54,36 @@
                 .setRate());
   }
 
-  public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
-    if (isRebaseOnBehalfOfUploader(cd)
-        && hasCodeReviewApprovalOfRealUploader(cd)
-        && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
-        && ignoresCodeReviewApprovalsOfUploader(cd)) {
-      // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
-      // The uploader of the patch set is the original uploader on whom's behalf the rebase was
-      // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
-      // clicking on the rebase button).
-      //
-      // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
-      //
-      // 3. The change doesn't have a Code-Review approval of any other user (a user that is not the
-      // real uploader).
-      //
-      // 4. Code-Review approvals of the uploader are ignored.
-      //
-      // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
-      // rebaser would have been the uploader of the patch set. In this case the Code-Review
-      // approval of the rebaser would not have counted since Code-Review approvals of the uploader
-      // are ignored.
-      //
-      // In this case we assume that the change would not be submittable if a normal rebase had been
-      // done. This is not always correct (e.g. if there are approvals of multiple reviewers) but
-      // it's good enough for the metric.
-      countChangesThatWereSubmittedWithRebaserApproval.increment();
+  public void countChangesThatWereSubmittedWithRebaserApproval(ChangeSet cs) {
+    for (ChangeData cd : cs.changes()) {
+      if (isRebaseOnBehalfOfUploader(cd)
+          && hasCodeReviewApprovalOfRealUploader(cd)
+          && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
+          && ignoresCodeReviewApprovalsOfUploader(cd)) {
+        // 1. The patch set that is being submitted was created by rebasing on behalf of the
+        // uploader.
+        //
+        // The uploader of the patch set is the original uploader on whose behalf the rebase was
+        // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g.
+        // by clicking on the rebase button).
+        //
+        // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
+        //
+        // 3. The change doesn't have a Code-Review approval of any other user (a user that is not
+        // the real uploader).
+        //
+        // 4. Code-Review approvals of the uploader are ignored.
+        //
+        // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
+        // rebaser would have been the uploader of the patch set. In this case the Code-Review
+        // approval of the rebaser would not have counted since Code-Review approvals of the
+        // uploader are ignored.
+        //
+        // In this case we assume that the change would not be submittable if a normal rebase had
+        // been done. This is not always correct (e.g. if there are approvals of multiple reviewers)
+        // but it's good enough for the metric.
+        countChangesThatWereSubmittedWithRebaserApproval.increment();
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index bc14f34..eb37ac2 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE;
 import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE;
 import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.REBASE_MERGE_COMMITS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.RetryableAction.ActionType.INDEX_QUERY;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
@@ -70,6 +69,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -84,6 +84,8 @@
 import com.google.gerrit.server.logging.TraceContext;
 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.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -94,6 +96,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.SubmissionExecutor;
@@ -112,6 +115,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Deque;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
@@ -150,6 +154,10 @@
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
       SUBMIT_RULE_OPTIONS.toBuilder().recomputeOnClosedChanges(true).build();
 
+  /**
+   * For each individual change in merge set aggregates issues and other details throughout the
+   * merge process.
+   */
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
     private final ImmutableSetMultimap<BranchNameKey, Change.Id> byBranch;
@@ -277,6 +285,7 @@
 
   private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final InternalUser.Factory internalUserFactory;
   private final MergeSuperSet mergeSuperSet;
   private final MergeValidators.Factory mergeValidatorsFactory;
@@ -291,6 +300,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
   private final MergeMetrics mergeMetrics;
+  private final PermissionBackend permissionBackend;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -316,6 +326,7 @@
   MergeOp(
       ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       InternalUser.Factory internalUserFactory,
       MergeSuperSet mergeSuperSet,
       MergeValidators.Factory mergeValidatorsFactory,
@@ -334,9 +345,11 @@
       MergeMetrics mergeMetrics,
       ProjectCache projectCache,
       ExperimentFeatures experimentFeatures,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      PermissionBackend permissionBackend) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.internalUserFactory = internalUserFactory;
     this.mergeSuperSet = mergeSuperSet;
     this.mergeValidatorsFactory = mergeValidatorsFactory;
@@ -359,6 +372,7 @@
     hasImplicitMergeTimeoutSeconds =
         ConfigUtil.getTimeUnit(
             config, "change", null, "implicitMergeCalculationTimeout", 60, TimeUnit.SECONDS);
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -368,6 +382,12 @@
     }
   }
 
+  /**
+   * Check that SRs are fulfilled or throw otherwise
+   *
+   * @param cd change that is being checked
+   * @throws ResourceConflictException the exception that is thrown if the SR is not fulfilled
+   */
   public static void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
@@ -422,32 +442,177 @@
     return cd.submitRecords(submitRuleOptions(/* allowClosed= */ false));
   }
 
-  private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
-      throws ResourceConflictException {
-    checkArgument(
-        !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
-    for (ChangeData cd : cs.changes()) {
-      try {
-        if (!cd.change().isNew()) {
-          if (!(cd.change().isMerged() && allowMerged)) {
-            commitStatus.problem(
-                cd.getId(), "Change " + cd.getId() + " is " + ChangeUtil.status(cd.change()));
-          }
-        } else if (cd.change().isWorkInProgress()) {
-          commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
-        } else {
-          checkSubmitRequirements(cd);
-          mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cd);
-        }
-      } catch (ResourceConflictException e) {
-        commitStatus.problem(cd.getId(), e.getMessage());
-      } catch (StorageException e) {
-        String msg = "Error checking submit rules for change";
-        logger.atWarning().withCause(e).log("%s %s", msg, cd.getId());
-        commitStatus.problem(cd.getId(), msg);
-      }
+  /** A problem preventing merge and change on which it occurred. */
+  @AutoValue
+  public abstract static class ChangeProblem {
+    public abstract Change.Id getChangeId();
+
+    public abstract String getProblem();
+
+    public static ChangeProblem create(Change.Id changeId, String problem) {
+      return new AutoValue_MergeOp_ChangeProblem(changeId, problem);
     }
+  }
+
+  private static void addProblemForChange(
+      Change.Id triggeringChangeId,
+      ChangeData cd,
+      boolean allowMerged,
+      PermissionBackend permissionBackend,
+      CurrentUser caller,
+      ImmutableList.Builder<ChangeProblem> problems) {
+    try {
+      Set<ChangePermission> can =
+          permissionBackend
+              .user(caller.getRealUser())
+              .change(cd)
+              .test(
+                  EnumSet.of(
+                      ChangePermission.READ, ChangePermission.SUBMIT, ChangePermission.SUBMIT_AS));
+      if (!can.contains(ChangePermission.READ)) {
+        // The READ permission should already be handled during generation of ChangeSet, however
+        // MergeSuperSetComputation is an interface and on API level doesn't guarantee that this
+        // have been verified for all changes. Additionally, this protects against potential
+        // issues due to staleness.
+        logger.atFine().log(
+            "Change %d cannot be submitted by user %s because it depends on change %d which the"
+                + "user cannot read",
+            triggeringChangeId.get(), caller.getRealUser().getLoggableName(), cd.getId().get());
+        problems.add(
+            ChangeProblem.create(
+                cd.getId(),
+                String.format(
+                    "Change %d depends on other hidden changes", triggeringChangeId.get())));
+        return;
+      }
+      if (!can.contains(ChangePermission.SUBMIT)) {
+        logger.atFine().log(
+            "Change %d cannot be submitted by user %s because it depends on change %d which the"
+                + "user cannot submit",
+            triggeringChangeId.get(), caller.getRealUser().getLoggableName(), cd.getId().get());
+        problems.add(
+            ChangeProblem.create(
+                cd.getId(),
+                String.format("Insufficient permission to submit change %d", cd.getId().get())));
+        return;
+      }
+      if (caller.isImpersonating()) {
+        if (!permissionBackend.user(caller).change(cd).test(ChangePermission.READ)) {
+          logger.atFine().log(
+              "Change %d cannot be submitted by user %s on behalf of user %s because it depends on"
+                  + " change %d which the on-behalf-of user does not have READ permission for",
+              triggeringChangeId.get(),
+              caller.getRealUser().getLoggableName(),
+              caller.getLoggableName(),
+              cd.getId().get());
+          problems.add(
+              ChangeProblem.create(
+                  cd.getId(),
+                  String.format(
+                      "On-behalf-of user %s lacks permission to read change %d",
+                      caller.getLoggableName(), cd.getId().get())));
+          return;
+        }
+        if (!can.contains(ChangePermission.SUBMIT_AS)) {
+          logger.atFine().log(
+              "Change %d cannot be submitted by user %s on behalf of user %s because it depends on"
+                  + " change %d which the user does not have SUBMIT_AS permission for",
+              triggeringChangeId.get(),
+              caller.getRealUser().getLoggableName(),
+              caller.getLoggableName(),
+              cd.getId().get());
+          problems.add(
+              ChangeProblem.create(
+                  cd.getId(),
+                  String.format(
+                      "Insufficient permission to submit change %d on behalf of user %s",
+                      cd.getId().get(), caller.getLoggableName())));
+          return;
+        }
+      }
+      if (!cd.change().isNew()) {
+        if (!(cd.change().isMerged() && allowMerged)) {
+          problems.add(
+              ChangeProblem.create(
+                  cd.getId(),
+                  String.format(
+                      "Change %d is %s", cd.getId().get(), ChangeUtil.status(cd.change()))));
+          return;
+        }
+      }
+      if (cd.change().isWorkInProgress()) {
+        problems.add(
+            ChangeProblem.create(
+                cd.getId(),
+                String.format("Change %d is marked work in progress", cd.getId().get())));
+        return;
+      }
+      try {
+        checkSubmitRequirements(cd);
+      } catch (ResourceConflictException e) {
+        // ResourceConflictException is thrown means submit requirement is not fulfilled.
+        problems.add(
+            ChangeProblem.create(
+                cd.getId(),
+                triggeringChangeId.equals(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",
+                        triggeringChangeId, cd.getId(), cd.getId(), e.getMessage())));
+      }
+    } catch (StorageException | PermissionBackendException e) {
+      String msg = "Error checking submit rules for change";
+      logger.atWarning().withCause(e).log("%s %s", msg, triggeringChangeId);
+      problems.add(ChangeProblem.create(cd.getId(), msg));
+    }
+  }
+
+  /**
+   * Returns a list of messages describing what prevents the current change from being submitted.
+   *
+   * <p>The method checks all changes in the {@code cs} for their current status, submitability and
+   * permissions and returns one change per change in the set that can't be submitted.
+   *
+   * @param triggeringChange Change for which merge/submit action was initiated
+   * @param cs Set of changes that the current change depends on
+   * @param allowMerged True if change being already merged is not a problem to be reported
+   * @param permissionBackend Interface for checking user ACLs
+   * @param caller the identity of the user that is recorded as the one performing the merge. In
+   *     case of impersonation {@code caller.getRealUser()} contains the user triggering the merge.
+   * @return List of problems preventing merge
+   */
+  public static ImmutableList<ChangeProblem> checkCommonSubmitProblems(
+      Change triggeringChange,
+      ChangeSet cs,
+      boolean allowMerged,
+      PermissionBackend permissionBackend,
+      CurrentUser caller) {
+    ImmutableList.Builder<ChangeProblem> problems = ImmutableList.builder();
+    if (cs.furtherHiddenChanges()) {
+      logger.atFine().log(
+          "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
+          triggeringChange.getId().get(),
+          caller.getRealUser().getLoggableName(),
+          cs.nonVisibleChanges());
+      problems.add(
+          ChangeProblem.create(
+              triggeringChange.getId(),
+              String.format(
+                  "Change %d depends on other hidden changes", triggeringChange.getId().get())));
+    }
+    for (ChangeData cd : cs.changes()) {
+      addProblemForChange(
+          triggeringChange.getId(), cd, allowMerged, permissionBackend, caller, problems);
+    }
+    return problems.build();
+  }
+
+  private void checkSubmitRulesAndState(Change triggeringChange, ChangeSet cs, boolean allowMerged)
+      throws ResourceConflictException {
+    checkCommonSubmitProblems(triggeringChange, cs, allowMerged, permissionBackend, caller).stream()
+        .forEach(cp -> commitStatus.problem(cp.getChangeId(), cp.getProblem()));
     commitStatus.maybeFailVerbose();
+    mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cs);
   }
 
   private void bypassSubmitRulesAndRequirements(ChangeSet cs) {
@@ -487,14 +652,21 @@
    * integration strategy.
    *
    * @param change the change to be merged.
-   * @param caller the identity of the caller
-   * @param checkSubmitRules whether the prolog submit rules should be evaluated
+   * @param caller the identity of the user that is recorded as the one performing the merge. In
+   *     case of impersonation {@code caller.getRealUser()} contains the user triggering the merge.
+   * @param checkSubmitRules whether submit rules and submit requirements should be evaluated.
    * @param submitInput parameters regarding the merge
+   * @param dryrun if true, this includes calculating all projects affected by the submission,
+   *     checking for possible submission problems (ACLs, merge conflicts, etc) but not the merge
+   *     itself.
    * @throws RestApiException if an error occurred.
    * @throws PermissionBackendException if permissions can't be checked
    * @throws IOException an error occurred reading from NoteDb.
    * @return the merged change
    */
+  // TODO: dryrun was introduced in https://gerrit-review.git.corp.google.com/c/gerrit/+/82911 and
+  // has never been used. Consider removing it. Since it was never used  and this file has been
+  // through many refactorings since, it's likely that the implementation is broken.
   @CanIgnoreReturnValue
   public Change merge(
       Change change,
@@ -558,7 +730,7 @@
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(batchUpdates, dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         @SuppressWarnings("unused")
         var unused =
@@ -577,7 +749,7 @@
                       this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
                       if (checkSubmitRules) {
                         logger.atFine().log("Checking submit rules and state");
-                        checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
+                        checkSubmitRulesAndState(change, filteredNoteDbChangeSet, isRetry);
                       } else {
                         logger.atFine().log("Bypassing submit rules");
                         bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
@@ -628,7 +800,7 @@
                     Change reloadChange = change;
                     ChangeSet indexBackedMergeChangeSet =
                         mergeSuperSet.completeChangeSet(
-                            reloadChange, caller, /* includingTopicClosure= */ false);
+                            reloadChange, caller.getRealUser(), /* includingTopicClosure= */ false);
                     if (!indexBackedMergeChangeSet.ids().contains(reloadChange.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
@@ -873,9 +1045,7 @@
         GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE, project)) {
       return;
     }
-    boolean rebaseMergeCommits = experimentFeatures.isFeatureEnabled(REBASE_MERGE_COMMITS, project);
-    if (submitType == SubmitType.CHERRY_PICK
-        || (rebaseMergeCommits && submitType == SubmitType.REBASE_ALWAYS)) {
+    if (submitType == SubmitType.CHERRY_PICK || submitType == SubmitType.REBASE_ALWAYS) {
       return;
     }
 
@@ -1022,7 +1192,8 @@
                 .map(c -> ObjectId.toString(c))
                 .collect(joining(", "));
         logger.atWarning().log(
-            "Timeout during hasImplicitMerge calculation. Number of iterations: %s, commitsToSubmit: %s",
+            "Timeout during hasImplicitMerge calculation. Number of iterations: %s,"
+                + " commitsToSubmit: %s",
             iterationCount, allCommits);
         return true;
       }
diff --git a/java/com/google/gerrit/server/submit/RebaseSorter.java b/java/com/google/gerrit/server/submit/RebaseSorter.java
index 3645d3f..d769ddd 100644
--- a/java/com/google/gerrit/server/submit/RebaseSorter.java
+++ b/java/com/google/gerrit/server/submit/RebaseSorter.java
@@ -31,6 +31,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
@@ -65,8 +66,10 @@
   public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort) throws IOException {
     final List<CodeReviewCommit> sorted = new ArrayList<>();
     final Set<CodeReviewCommit> sort = new HashSet<>(toSort);
+    final Set<ObjectId> uninterestingParents = new HashSet<>();
     while (!sort.isEmpty()) {
       final CodeReviewCommit n = removeOne(sort);
+      collectUninterestingParents(n, uninterestingParents);
 
       rw.resetRetain(canMergeFlag);
       rw.markStart(n);
@@ -77,6 +80,8 @@
       CodeReviewCommit c;
       final List<CodeReviewCommit> contents = new ArrayList<>();
       while ((c = rw.next()) != null) {
+        collectUninterestingParents(c, uninterestingParents);
+
         if (!c.has(canMergeFlag) || !incoming.contains(c)) {
           if (isMergedInBranchAsSubmittedChange(c, n.change().getDest())
               || isAlreadyMergedInAnyBranch(c)) {
@@ -106,9 +111,27 @@
       sorted.removeAll(contents);
       sorted.addAll(contents);
     }
+    sorted.removeAll(uninterestingParents);
     return sorted;
   }
 
+  /**
+   * Rebasing merge commits is done by rebasing their first parent commit, i.e. the first parent is
+   * updated to the new base while the second parent stays intact. This means for chains of changes
+   * we only need to rebase changes that are reachable via the first parents. Changes that are
+   * reachable via second parents do not need to be rebased (since the second parent of merge
+   * commits stays intact) which is why we filter them out here by marking them as uninteresting.
+   */
+  private void collectUninterestingParents(CodeReviewCommit c, Set<ObjectId> uninterestingParents)
+      throws IOException {
+    if (c.getParentCount() > 0) {
+      for (int parent = 1; parent < c.getParentCount(); parent++) {
+        uninterestingParents.add(c.getParent(parent));
+        rw.markUninteresting(c.getParent(parent));
+      }
+    }
+  }
+
   private boolean isAlreadyMergedInAnyBranch(CodeReviewCommit commit) throws IOException {
     try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
       mirw.reset();
diff --git a/java/com/google/gerrit/server/submit/RebaseSorterNew.java b/java/com/google/gerrit/server/submit/RebaseSorterNew.java
deleted file mode 100644
index 7fbabb4..0000000
--- a/java/com/google/gerrit/server/submit/RebaseSorterNew.java
+++ /dev/null
@@ -1,178 +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.submit;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-
-/**
- * This is a temporary copy of {@code RebaseSorter} with an extension to filter out secondary
- * parents, which is needed for rebasing merge commits. Rebasing merge commits is protected by an
- * experiment flag, which means for now we have to keep the old logic working (hence we cannot
- * replace the original {@code RebaseSorter} yet. Once the experiment was successful, we will
- * replace {@code RebaseSorter} with this class.
- */
-public class RebaseSorterNew {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final CurrentUser caller;
-  private final CodeReviewRevWalk rw;
-  private final RevFlag canMergeFlag;
-  private final RevCommit initialTip;
-  private final Set<RevCommit> alreadyAccepted;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Set<CodeReviewCommit> incoming;
-
-  public RebaseSorterNew(
-      CurrentUser caller,
-      CodeReviewRevWalk rw,
-      RevCommit initialTip,
-      Set<RevCommit> alreadyAccepted,
-      RevFlag canMergeFlag,
-      Provider<InternalChangeQuery> queryProvider,
-      Set<CodeReviewCommit> incoming) {
-    this.caller = caller;
-    this.rw = rw;
-    this.canMergeFlag = canMergeFlag;
-    this.initialTip = initialTip;
-    this.alreadyAccepted = alreadyAccepted;
-    this.queryProvider = queryProvider;
-    this.incoming = incoming;
-  }
-
-  public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort) throws IOException {
-    final List<CodeReviewCommit> sorted = new ArrayList<>();
-    final Set<CodeReviewCommit> sort = new HashSet<>(toSort);
-    final Set<ObjectId> uninterestingParents = new HashSet<>();
-    while (!sort.isEmpty()) {
-      final CodeReviewCommit n = removeOne(sort);
-      collectUninterestingParents(n, uninterestingParents);
-
-      rw.resetRetain(canMergeFlag);
-      rw.markStart(n);
-      if (initialTip != null) {
-        rw.markUninteresting(initialTip);
-      }
-
-      CodeReviewCommit c;
-      final List<CodeReviewCommit> contents = new ArrayList<>();
-      while ((c = rw.next()) != null) {
-        collectUninterestingParents(c, uninterestingParents);
-
-        if (!c.has(canMergeFlag) || !incoming.contains(c)) {
-          if (isMergedInBranchAsSubmittedChange(c, n.change().getDest())
-              || isAlreadyMergedInAnyBranch(c)) {
-            rw.markUninteresting(c);
-          } else {
-            // We cannot merge n as it would bring something we
-            // aren't permitted to merge at this time. Drop n.
-            //
-            n.setStatusCode(CommitMergeStatus.MISSING_DEPENDENCY);
-            n.setStatusMessage(
-                CommitMergeStatus.createMissingDependencyMessage(
-                    caller, queryProvider, n.name(), c.name()));
-          }
-          // Stop RevWalk because c is either a merged commit or a missing
-          // dependency. Not need to walk further.
-          break;
-        }
-        contents.add(c);
-      }
-
-      if (n.getStatusCode() == CommitMergeStatus.MISSING_DEPENDENCY) {
-        continue;
-      }
-
-      sort.removeAll(contents);
-      Collections.reverse(contents);
-      sorted.removeAll(contents);
-      sorted.addAll(contents);
-    }
-    sorted.removeAll(uninterestingParents);
-    return sorted;
-  }
-
-  /**
-   * Rebasing merge commits is done by rebasing their first parent commit, i.e. the first parent is
-   * updated to the new base while the second parent stays intact. This means for chains of changes
-   * we only need to rebase changes that are reachable via the first parents. Changes that are
-   * reachable via second parents do not need to be rebased (since the second parent of merge
-   * commits stays intact) which is why we filter them out here by marking them as uninteresting.
-   */
-  private void collectUninterestingParents(CodeReviewCommit c, Set<ObjectId> uninterestingParents)
-      throws IOException {
-    if (c.getParentCount() > 0) {
-      for (int parent = 1; parent < c.getParentCount(); parent++) {
-        uninterestingParents.add(c.getParent(parent));
-        rw.markUninteresting(c.getParent(parent));
-      }
-    }
-  }
-
-  private boolean isAlreadyMergedInAnyBranch(CodeReviewCommit commit) throws IOException {
-    try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
-      mirw.reset();
-      mirw.markStart(commit);
-      // check if the commit is merged in other branches
-      for (RevCommit accepted : alreadyAccepted) {
-        if (mirw.isMergedInto(mirw.parseCommit(commit), mirw.parseCommit(accepted))) {
-          logger.atFine().log(
-              "Dependency %s merged into branch head %s.", commit.getName(), accepted.getName());
-          return true;
-        }
-      }
-      return false;
-    } catch (StorageException e) {
-      throw new IOException(e);
-    }
-  }
-
-  private boolean isMergedInBranchAsSubmittedChange(CodeReviewCommit commit, BranchNameKey dest) {
-    List<ChangeData> changes = queryProvider.get().byBranchCommit(dest, commit.getId().getName());
-    for (ChangeData change : changes) {
-      if (change.change().isMerged()) {
-        logger.atFine().log(
-            "Dependency %s associated with merged change %s.", commit.getName(), change.getId());
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private static <T> T removeOne(Collection<T> c) {
-    final Iterator<T> i = c.iterator();
-    final T r = i.next();
-    i.remove();
-    return r;
-  }
-}
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 9ba1f48..e3d7fc4 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -15,12 +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.experiments.ExperimentFeaturesConstants.REBASE_MERGE_COMMITS;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
@@ -43,13 +40,10 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 
 /** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
 public class RebaseSubmitStrategy extends SubmitStrategy {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final boolean rebaseAlways;
 
   RebaseSubmitStrategy(SubmitStrategy.Arguments args, boolean rebaseAlways) {
@@ -59,55 +53,13 @@
 
   @Override
   public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
-    boolean rebaseMergeCommits =
-        args.experimentFeatures.isFeatureEnabled(REBASE_MERGE_COMMITS, args.destBranch.project());
-    logger.atFine().log("rebaseMergeCommits=%s", rebaseMergeCommits);
-
     List<CodeReviewCommit> sorted;
     try {
-      sorted =
-          rebaseMergeCommits ? args.rebaseSorterNew.sort(toMerge) : args.rebaseSorter.sort(toMerge);
+      sorted = args.rebaseSorter.sort(toMerge);
     } catch (IOException | StorageException e) {
       throw new StorageException("Commit sorting failed", e);
     }
 
-    if (!rebaseMergeCommits) {
-      // We cannot rebase merge commits. This is why we integrate merge changes into the target
-      // branch the same way as if MERGE_IF_NECESSARY was the submit strategy. This means if needed
-      // we create a merge commit that integrates the merge change into the target branch.
-      // If we integrate a change series that consists out of a normal change and a merge change,
-      // where the merge change depends on the normal change, we must skip rebasing the normal
-      // change, because it already gets integrated by merging the merge change. If the rebasing of
-      // the  normal change is not skipped, it would appear twice in the history after the submit is
-      // done (once through its rebased commit, and once through its original commit which is a
-      // parent of the merge change that was merged into the target branch. To skip the rebasing of
-      // the normal change, we call MergeUtil#reduceToMinimalMerge, as it excludes commits which
-      // will be implicitly integrated by merging the series. Then we use the MergeIfNecessaryOp to
-      // integrate the whole series.
-      // If on the other hand, we integrate a change series that consists out of a merge change and
-      // a normal change, where the normal change depends on the merge change, we can first
-      // integrate the merge change by a merge and then integrate the normal change by a rebase. In
-      // this case we do not want to call MergeUtil#reduceToMinimalMerge as we are not intending to
-      // integrate the whole series by a merge, but rather do the integration of the commits one by
-      // one.
-      boolean foundNonMerge = false;
-      for (CodeReviewCommit c : sorted) {
-        if (c.getParentCount() > 1) {
-          if (!foundNonMerge) {
-            // found a merge change, but it doesn't depend on a normal change, this means we are not
-            // required to merge the whole series at once
-            continue;
-          }
-          // 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(toImmutableList());
-        }
-        foundNonMerge = true;
-      }
-    }
-
     ImmutableList.Builder<SubmitStrategyOp> ops =
         ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
@@ -119,12 +71,8 @@
         ops.add(new FastForwardOp(args, n));
       } else if (n.getParentCount() == 0) {
         ops.add(new RebaseRootOp(n));
-      } else if (rebaseMergeCommits) {
-        ops.add(new RebaseOneOp(n));
-      } else if (n.getParentCount() == 1) {
-        ops.add(new RebaseOneOp(n));
       } else {
-        ops.add(new MergeIfNecessaryOp(n));
+        ops.add(new RebaseOneOp(n));
       }
       first = false;
     }
@@ -270,49 +218,6 @@
     }
   }
 
-  private class MergeIfNecessaryOp extends SubmitStrategyOp {
-    private MergeIfNecessaryOp(CodeReviewCommit toMerge) {
-      super(RebaseSubmitStrategy.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
-      // There are multiple parents, so this is a merge commit. We don't want
-      // to rebase the merge as clients can't easily rebase their history with
-      // that merge present and replaced by an equivalent merge with a different
-      // first parent. So instead behave as though MERGE_IF_NECESSARY was
-      // configured.
-      // TODO(tandrii): this is not in spirit of RebaseAlways strategy because
-      // the commit messages can not be modified in the process. It's also
-      // possible to implement rebasing of merge commits. E.g., the Cherry Pick
-      // REST endpoint already supports cherry-picking of merge commits.
-      // For now, users of RebaseAlways strategy for whom changed commit footers
-      // are important would be well advised to prohibit uploading patches with
-      // merge commits.
-      MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
-        mergeTip.moveTipTo(toMerge, toMerge);
-      } else {
-        PersonIdent caller = ctx.newCommitterIdent();
-        CodeReviewCommit newTip =
-            args.mergeUtil.mergeOneCommit(
-                caller,
-                caller,
-                args.rw,
-                ctx.getInserter(),
-                ctx.getRepoView().getConfig(),
-                args.destBranch,
-                mergeTip.getCurrentTip(),
-                toMerge);
-        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
-      }
-      args.mergeUtil.markCleanMerges(
-          args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
-      acceptMergeTip(mergeTip);
-    }
-  }
-
   private void acceptMergeTip(MergeTip mergeTip) {
     args.alreadyAccepted.add(mergeTip.getCurrentTip());
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 4b95685..5346acc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -38,7 +38,7 @@
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.TestSubmitInput;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -67,6 +67,7 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
@@ -116,6 +117,7 @@
     final EmailMerge.Factory mergedSenderFactory;
     final GitRepositoryManager repoManager;
     final LabelNormalizer labelNormalizer;
+    final Config cfg;
     final PatchSetInfoFactory patchSetInfoFactory;
     final PatchSetUtil psUtil;
     final ProjectCache projectCache;
@@ -127,7 +129,6 @@
     final ProjectConfig.Factory projectConfigFactory;
     final SetPrivateOp.Factory setPrivateOpFactory;
     final SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff;
-    final ExperimentFeatures experimentFeatures;
 
     final BranchNameKey destBranch;
     final CodeReviewRevWalk rw;
@@ -145,7 +146,6 @@
     final ProjectState project;
     final MergeSorter mergeSorter;
     final RebaseSorter rebaseSorter;
-    final RebaseSorterNew rebaseSorterNew;
     final MergeUtil mergeUtil;
     final boolean dryrun;
 
@@ -161,6 +161,7 @@
         MergeUtilFactory mergeUtilFactory,
         PatchSetInfoFactory patchSetInfoFactory,
         PatchSetUtil psUtil,
+        @GerritServerConfig Config cfg,
         @GerritPersonIdent PersonIdent serverIdent,
         ProjectCache projectCache,
         RebaseChangeOp.Factory rebaseFactory,
@@ -170,7 +171,6 @@
         ProjectConfig.Factory projectConfigFactory,
         SetPrivateOp.Factory setPrivateOpFactory,
         SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff,
-        ExperimentFeatures experimentFeatures,
         @Assisted BranchNameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
@@ -193,6 +193,7 @@
       this.cmUtil = cmUtil;
       this.labelNormalizer = labelNormalizer;
       this.projectConfigFactory = projectConfigFactory;
+      this.cfg = cfg;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.psUtil = psUtil;
       this.projectCache = projectCache;
@@ -201,7 +202,6 @@
       this.queryProvider = queryProvider;
       this.setPrivateOpFactory = setPrivateOpFactory;
       this.submitWithStickyApprovalDiff = submitWithStickyApprovalDiff;
-      this.experimentFeatures = experimentFeatures;
 
       this.serverIdent = serverIdent;
       this.destBranch = destBranch;
@@ -231,15 +231,6 @@
               canMergeFlag,
               queryProvider,
               incoming);
-      this.rebaseSorterNew =
-          new RebaseSorterNew(
-              caller,
-              rw,
-              mergeTip.getInitialTip(),
-              alreadyAccepted,
-              canMergeFlag,
-              queryProvider,
-              incoming);
       this.mergeUtil = mergeUtilFactory.create(project);
       this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
     }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 96dc326..6f58560 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -22,6 +22,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
@@ -43,6 +44,7 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -78,6 +80,7 @@
   private CodeReviewCommit alreadyMergedCommit;
   private boolean changeAlreadyMerged;
   private String stickyApprovalDiff;
+  private ImmutableList<FileDiffOutput> modifiedFiles;
 
   protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
     this.args = args;
@@ -440,7 +443,10 @@
   private String message(ChangeContext ctx, String body)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
-    stickyApprovalDiff = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
+    modifiedFiles = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
+    stickyApprovalDiff =
+        args.submitWithStickyApprovalDiff.computeDiffFromModifiedFiles(
+            ctx.getNotes(), ctx.getUser(), modifiedFiles);
     return body + stickyApprovalDiff;
   }
 
@@ -507,7 +513,8 @@
               args.caller,
               ctx.getNotify(getId()),
               ctx.getRepoView(),
-              stickyApprovalDiff)
+              stickyApprovalDiff,
+              modifiedFiles)
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index cebb5e3..2402357 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
@@ -39,13 +39,16 @@
 
   @Singleton
   public static class Factory {
+    private final BatchUpdates batchUpdates;
     private final SubscriptionGraph.Factory subscriptionGraphFactory;
     private final SubmoduleCommits.Factory submoduleCommitsFactory;
 
     @Inject
     Factory(
+        BatchUpdates batchUpdates,
         SubscriptionGraph.Factory subscriptionGraphFactory,
         SubmoduleCommits.Factory submoduleCommitsFactory) {
+      this.batchUpdates = batchUpdates;
       this.subscriptionGraphFactory = subscriptionGraphFactory;
       this.submoduleCommitsFactory = submoduleCommitsFactory;
     }
@@ -54,6 +57,7 @@
         Map<BranchNameKey, ReceiveCommand> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleConflictException {
       return new SubmoduleOp(
+          batchUpdates,
           updatedBranches,
           orm,
           subscriptionGraphFactory.compute(updatedBranches.keySet(), orm),
@@ -61,6 +65,7 @@
     }
   }
 
+  private final BatchUpdates batchUpdates;
   private final Map<BranchNameKey, ReceiveCommand> updatedBranches;
   private final MergeOpRepoManager orm;
   private final SubscriptionGraph subscriptionGraph;
@@ -68,10 +73,12 @@
   private final UpdateOrderCalculator updateOrderCalculator;
 
   private SubmoduleOp(
+      BatchUpdates batchUpdates,
       Map<BranchNameKey, ReceiveCommand> updatedBranches,
       MergeOpRepoManager orm,
       SubscriptionGraph subscriptionGraph,
       SubmoduleCommits submoduleCommits) {
+    this.batchUpdates = batchUpdates;
     this.updatedBranches = updatedBranches;
     this.orm = orm;
     this.subscriptionGraph = subscriptionGraph;
@@ -107,7 +114,7 @@
         }
       }
       try (RefUpdateContext ctx = RefUpdateContext.open(UPDATE_SUPERPROJECT)) {
-        BatchUpdate.execute(
+        batchUpdates.execute(
             orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
             ImmutableList.of(),
             dryrun);
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java
new file mode 100644
index 0000000..389c7f4
--- /dev/null
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java
@@ -0,0 +1,213 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submitrequirement.predicate;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.Enums;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gerrit.server.query.change.LabelPredicate;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Extensions of the {@link LabelPredicate} that are only available for submit requirement
+ * expressions, but not for search.
+ *
+ * <p>Supported extensions:
+ *
+ * <ul>
+ *   <li>"users=human_reviewers" arg, e.g. "label:Code-Review=MAX,users=human_reviewers" matches
+ *       changes where all human reviewers have approved the change with Code-Review=MAX
+ * </ul>
+ */
+public class SubmitRequirementLabelExtensionPredicate extends SubmitRequirementPredicate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    SubmitRequirementLabelExtensionPredicate create(String value) throws QueryParseException;
+  }
+
+  private static final Pattern PATTERN = Pattern.compile("(?<label>[^,]*),users=human_reviewers$");
+  private static final Pattern PATTERN_LABEL =
+      Pattern.compile("(?<label>[^,<>=]*)(?<op>=|<=|>=|<|>)(?<value>[^,]*)");
+
+  public static boolean matches(String value) {
+    return PATTERN.matcher(value).matches();
+  }
+
+  public static void validateIfNoMatch(String value) throws QueryParseException {
+    if (value.contains(",users=")) {
+      throw new QueryParseException(
+          "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+              + " group')");
+    }
+  }
+
+  private final Arguments args;
+  private final ServiceUserClassifier serviceUserClassifier;
+  private final String label;
+
+  @Inject
+  SubmitRequirementLabelExtensionPredicate(
+      Arguments args, ServiceUserClassifier serviceUserClassifier, @Assisted String value)
+      throws QueryParseException {
+    super("label", value);
+    this.args = args;
+    this.serviceUserClassifier = serviceUserClassifier;
+
+    Matcher m = PATTERN.matcher(value);
+    if (!m.matches()) {
+      throw new QueryParseException(
+          String.format("invalid value for '%s': %s", getOperator(), value));
+    }
+    this.label = validateLabel(m.group("label"));
+  }
+
+  @CanIgnoreReturnValue
+  private String validateLabel(String label) throws QueryParseException {
+    int eq = label.indexOf('=');
+
+    if (eq <= 0) {
+      return label;
+    }
+
+    String statusName = label.substring(eq + 1).toUpperCase(Locale.US);
+    SubmitRecord.Label.Status status =
+        Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
+    if (status != null) {
+      // We would need to use SubmitRecordPredicate but can't because it doesn't implement
+      // Matchable.
+      throw new QueryParseException(
+          "Cannot use the 'users=human_reviewers' argument in conjunction with a submit record"
+              + " label status");
+    }
+    return label;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    if (!cd.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).isEmpty()
+        && !matchZeroVotes(label)) {
+      // Reviewers by email are reviewers that don't have a Gerrit account. Without Gerrit
+      // account they cannot vote on the change, which means changes that have any such
+      // reviewers never match when we expect a vote != 0 from all reviewers.
+      logger.atFine().log(
+          "change %s doesn't match since there are reviewers by email"
+              + " (that don't have a matching approval): %s",
+          cd.change().getChangeId(), cd.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+      return false;
+    }
+
+    ImmutableSet<Account.Id> humanReviewers =
+        cd.reviewers().byState(ReviewerStateInternal.REVIEWER).stream()
+            // Ignore the change owner (if the change owner voted on their own change they are
+            // technically a reviewer).
+            .filter(accountId -> !accountId.equals(cd.change().getOwner()))
+            // Ignore reviewers that are service users.
+            .filter(accountId -> !serviceUserClassifier.isServiceUser(accountId))
+            .collect(toImmutableSet());
+
+    if (humanReviewers.isEmpty()) {
+      // a review from human reviewers is required, but no human reviewers are present
+      return false;
+    }
+
+    for (Account.Id reviewer : humanReviewers) {
+      if (!new LabelPredicate(
+              args,
+              label,
+              ImmutableSet.of(reviewer),
+              /* group= */ null,
+              /* count= */ null,
+              /* countOp= */ null)
+          .match(cd)) {
+        logger.atFine().log(
+            "change %s doesn't match because it misses matching approvals from: %s",
+            cd.change().getChangeId(), reviewer);
+        return false;
+      }
+    }
+
+    logger.atFine().log(
+        "change %s matches because it has matching approvals from all human reviewers: %s",
+        cd.change().getChangeId(), humanReviewers);
+    return true;
+  }
+
+  private boolean matchZeroVotes(String label) {
+    Matcher m = PATTERN_LABEL.matcher(label);
+    if (!m.matches()) {
+      return false;
+    }
+
+    String op = m.group("op");
+    String value = m.group("value");
+
+    Optional<Integer> intValue = Optional.ofNullable(Ints.tryParse(value));
+
+    if (op.equals("=") && (intValue.isPresent() && intValue.get() == 0)) {
+      return true;
+    } else if (op.equals("<=")) {
+      if (intValue.isPresent() && intValue.get() >= 0) {
+        return true;
+      } else if (value.equals("MAX")) {
+        return true;
+      }
+      return false;
+    } else if (op.equals("<")) {
+      if (intValue.isPresent() && intValue.get() > 0) {
+        return true;
+      } else if (value.equals("MAX")) {
+        return true;
+      }
+    } else if (op.equals(">=")) {
+      if (intValue.isPresent() && intValue.get() <= 0) {
+        return true;
+      } else if (value.equals("MIN")) {
+        return true;
+      }
+      return false;
+    } else if (op.equals(">")) {
+      if (intValue.isPresent() && intValue.get() < 0) {
+        return true;
+      } else if (value.equals("MIN")) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index b28ab27..a980d15 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -17,8 +17,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
-import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
@@ -35,7 +33,6 @@
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multiset;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -51,9 +48,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
@@ -73,12 +67,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.LimitExceededException;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -91,10 +80,8 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.TreeMap;
-import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -103,7 +90,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
 
 /**
  * Helper for a set of change updates that should be applied to the NoteDb database.
@@ -142,118 +128,6 @@
     BatchUpdate create(Project.NameKey project, CurrentUser user, Instant when);
   }
 
-  public static void execute(
-      Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
-      throws UpdateException, RestApiException {
-    requireNonNull(listeners);
-    if (updates.isEmpty()) {
-      return;
-    }
-
-    checkDifferentProject(updates);
-
-    try {
-      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
-      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
-      try {
-        for (BatchUpdate u : updates) {
-          u.executeUpdateRepo();
-        }
-        notifyAfterUpdateRepo(listeners);
-        for (BatchUpdate u : updates) {
-          changesHandles.add(u.executeChangeOps(listeners, dryrun));
-        }
-        for (ChangesHandle h : changesHandles) {
-          h.execute();
-          if (h.requiresReindex()) {
-            indexFutures.addAll(h.startIndexFutures());
-          }
-        }
-        notifyAfterUpdateRefs(listeners);
-        notifyAfterUpdateChanges(listeners);
-      } finally {
-        for (ChangesHandle h : changesHandles) {
-          h.close();
-        }
-      }
-
-      Map<Change.Id, ChangeData> changeDatas =
-          Futures.allAsList(indexFutures).get().stream()
-              // filter out null values that were returned for change deletions
-              .filter(Objects::nonNull)
-              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates.forEach(BatchUpdate::fireRefChangeEvents);
-
-      if (!dryrun) {
-        for (BatchUpdate u : updates) {
-          u.executePostOps(changeDatas);
-        }
-      }
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-    }
-  }
-
-  private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateRepos();
-    }
-  }
-
-  private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateRefs();
-    }
-  }
-
-  private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateChanges();
-    }
-  }
-
-  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
-    Multiset<Project.NameKey> projectCounts =
-        updates.stream().map(u -> u.project).collect(toImmutableMultiset());
-    checkArgument(
-        projectCounts.entrySet().size() == updates.size(),
-        "updates must all be for different projects, got: %s",
-        projectCounts);
-  }
-
-  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
-    // Convert common non-REST exception types with user-visible messages to corresponding REST
-    // exception types.
-    if (e instanceof InvalidChangeOperationException || e instanceof LimitExceededException) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } else if (e instanceof NoSuchChangeException
-        || e instanceof NoSuchRefException
-        || e instanceof NoSuchProjectException) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    } else if (e instanceof CommentsRejectedException) {
-      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
-      // status code and it's isolated in monitoring.
-      throw new BadRequestException(e.getMessage(), e);
-    }
-
-    Throwables.throwIfUnchecked(e);
-
-    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
-    // ResourceConflictException to indicate an atomic update failure.
-    Throwables.throwIfInstanceOf(e, UpdateException.class);
-    Throwables.throwIfInstanceOf(e, RestApiException.class);
-
-    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
-    throw new UpdateException(e);
-  }
-
   class ContextImpl implements Context {
     private final CurrentUser contextUser;
 
@@ -409,6 +283,7 @@
     DELETED
   }
 
+  private final BatchUpdates batchUpdates;
   private final GitRepositoryManager repoManager;
   private final AccountCache accountCache;
   private final ChangeData.Factory changeDataFactory;
@@ -445,6 +320,7 @@
 
   @Inject
   BatchUpdate(
+      BatchUpdates batchUpdates,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
       AccountCache accountCache,
@@ -461,6 +337,7 @@
       @Assisted CurrentUser user,
       @Assisted Instant when) {
     this.gerritConfig = gerritConfig;
+    this.batchUpdates = batchUpdates;
     this.repoManager = repoManager;
     this.accountCache = accountCache;
     this.changeDataFactory = changeDataFactory;
@@ -484,12 +361,15 @@
     }
   }
 
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), ImmutableList.of(listener), false);
+  @CanIgnoreReturnValue
+  public BatchUpdates.Result execute(BatchUpdateListener listener)
+      throws UpdateException, RestApiException {
+    return batchUpdates.execute(ImmutableList.of(this), ImmutableList.of(listener), false);
   }
 
-  public void execute() throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), ImmutableList.of(), false);
+  @CanIgnoreReturnValue
+  public BatchUpdates.Result execute() throws UpdateException, RestApiException {
+    return batchUpdates.execute(ImmutableList.of(this), ImmutableList.of(), false);
   }
 
   public boolean isExecuted() {
@@ -584,7 +464,7 @@
    */
   public Map<BranchNameKey, ReceiveCommand> getSuccessfullyUpdatedBranches(boolean dryrun) {
     return getRefUpdates().entrySet().stream()
-        .filter(entry -> dryrun || entry.getValue().getResult() == Result.OK)
+        .filter(entry -> dryrun || entry.getValue().getResult() == ReceiveCommand.Result.OK)
         .collect(
             toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
   }
@@ -642,7 +522,7 @@
     return this;
   }
 
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
+  void executeUpdateRepo() throws UpdateException, RestApiException {
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
       for (Map.Entry<Change.Id, OpData<BatchUpdateOp>> e : ops.entries()) {
@@ -684,7 +564,7 @@
         && gerritConfig.getBoolean("index", "indexChangesAsync", false);
   }
 
-  private void fireRefChangeEvents() {
+  void fireRefChangeEvents() {
     batchRefUpdate.forEach(
         (projectName, bru) -> gitRefUpdated.fire(projectName, bru, getAccount().orElse(null)));
   }
@@ -701,7 +581,7 @@
     }
   }
 
-  private class ChangesHandle implements AutoCloseable {
+  class ChangesHandle implements AutoCloseable {
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
     private final Map<Change.Id, ChangeResult> results;
@@ -786,7 +666,7 @@
     }
   }
 
-  private ChangesHandle executeChangeOps(
+  ChangesHandle executeChangeOps(
       ImmutableList<BatchUpdateListener> batchUpdateListeners, boolean dryrun) throws Exception {
     logDebug("Executing change ops");
     initRepository();
@@ -818,7 +698,7 @@
           "Applying %d ops for change %s: %s",
           e.getValue().size(),
           id,
-          lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
+          e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet()));
       for (OpData<BatchUpdateOp> opData : e.getValue()) {
         if (ctx == null) {
           ctx = newChangeContext(opData.user(), id);
@@ -827,9 +707,11 @@
           ctx.distinctUpdates.values().forEach(changeUpdates::add);
           ctx = newChangeContext(opData.user(), id);
         }
+        Class<? extends BatchUpdateOp> opClass = opData.op().getClass();
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                opData.getClass().getSimpleName() + "#updateChange",
+                (opClass.isAnonymousClass() ? opClass.getName() : opClass.getSimpleName())
+                    + "#updateChange",
                 Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
           dirty |= opData.op().updateChange(ctx);
           deleted |= ctx.deleted;
@@ -914,7 +796,7 @@
     return new ChangeContextImpl(contextUser, notes);
   }
 
-  private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
+  void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
     for (OpData<BatchUpdateOp> opData : ops.values()) {
       PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
       try (TraceContext.TraceTimer ignored =
diff --git a/java/com/google/gerrit/server/update/BatchUpdates.java b/java/com/google/gerrit/server/update/BatchUpdates.java
new file mode 100644
index 0000000..aa727f1
--- /dev/null
+++ b/java/com/google/gerrit/server/update/BatchUpdates.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multiset;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.LimitExceededException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate.ChangesHandle;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Runs execute methods of a collection of {@link BatchUpdate}s calling listeners when appropriate.
+ *
+ * <p>This class does not maintain any state about the updates it executes. The only reason it is
+ * non-static is to provide convenient access to {@link
+ * com.google.gerrit.server.query.change.ChangeData.Factory} without needing to provide one as an
+ * argument.
+ */
+@Singleton
+public class BatchUpdates {
+  public class Result {
+    private final Map<Change.Id, ChangeData> changeDatas;
+
+    private Result() {
+      this(new HashMap<>());
+    }
+
+    private Result(Map<Change.Id, ChangeData> changeDatas) {
+      this.changeDatas = changeDatas;
+    }
+
+    /**
+     * Returns the updated {@link ChangeData} for the given project and change ID.
+     *
+     * <p>If the requested {@link ChangeData} was already loaded after the {@link BatchUpdate} has
+     * been executed the cached {@link ChangeData} instance is returned, otherwise the requested
+     * {@link ChangeData} is loaded and put into the cache.
+     */
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  BatchUpdates(ChangeData.Factory changeDataFactory) {
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  @CanIgnoreReturnValue
+  public Result execute(
+      Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
+      throws UpdateException, RestApiException {
+    requireNonNull(listeners);
+    if (updates.isEmpty()) {
+      return new Result();
+    }
+
+    checkDifferentProject(updates);
+
+    try {
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
+      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
+      try {
+        for (BatchUpdate u : updates) {
+          u.executeUpdateRepo();
+        }
+        notifyAfterUpdateRepo(listeners);
+        for (BatchUpdate u : updates) {
+          changesHandles.add(u.executeChangeOps(listeners, dryrun));
+        }
+        for (ChangesHandle h : changesHandles) {
+          h.execute();
+          if (h.requiresReindex()) {
+            indexFutures.addAll(h.startIndexFutures());
+          }
+        }
+        notifyAfterUpdateRefs(listeners);
+        notifyAfterUpdateChanges(listeners);
+      } finally {
+        for (ChangesHandle h : changesHandles) {
+          h.close();
+        }
+      }
+
+      Map<Change.Id, ChangeData> changeDatas =
+          Futures.allAsList(indexFutures).get().stream()
+              // filter out null values that were returned for change deletions
+              .filter(Objects::nonNull)
+              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates.forEach(BatchUpdate::fireRefChangeEvents);
+
+      if (!dryrun) {
+        for (BatchUpdate u : updates) {
+          u.executePostOps(changeDatas);
+        }
+      }
+
+      return new Result(changeDatas);
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+      return new Result();
+    }
+  }
+
+  private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRepos();
+    }
+  }
+
+  private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRefs();
+    }
+  }
+
+  private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateChanges();
+    }
+  }
+
+  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+    Multiset<Project.NameKey> projectCounts =
+        updates.stream().map(u -> u.getProject()).collect(toImmutableMultiset());
+    checkArgument(
+        projectCounts.entrySet().size() == updates.size(),
+        "updates must all be for different projects, got: %s",
+        projectCounts);
+  }
+
+  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    // Convert common non-REST exception types with user-visible messages to corresponding REST
+    // exception types.
+    if (e instanceof InvalidChangeOperationException || e instanceof LimitExceededException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } else if (e instanceof CommentsRejectedException) {
+      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
+      // status code and it's isolated in monitoring.
+      throw new BadRequestException(e.getMessage(), e);
+    }
+
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
+    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
+    throw new UpdateException(e);
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index d9af527..c5621ed 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -479,6 +479,12 @@
                 String actionName = opts.actionName().orElse("N/A");
                 String cause = formatCause(t);
 
+                // Do not retry if retrying was already done and failed.
+                if (Throwables.getCausalChain(t).stream()
+                    .anyMatch(RetryException.class::isInstance)) {
+                  return false;
+                }
+
                 // exceptionPredicate checks for temporary errors for which the operation should be
                 // retried (e.g. LockFailure). The retry has good chances to succeed.
                 if (exceptionPredicate.test(t)) {
@@ -596,6 +602,9 @@
             actionType,
             opts.actionName().orElse("N/A"),
             listener.getOriginalCause().map(this::formatCause).orElse("_unknown"));
+
+        // Re-throw the RetryException so that retrying is not re-attempted on an outer level.
+        throw e;
       }
       if (e.getCause() != null) {
         Throwables.throwIfUnchecked(e.getCause());
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 39eda58..ff46181 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -21,13 +21,18 @@
 import java.util.Optional;
 import java.util.stream.Collectors;
 
+/** Wrapper class for calling BatchUpdates.execute() that manages calls to submission listeners. */
 public class SubmissionExecutor {
-
+  private final BatchUpdates batchUpdates;
   private final ImmutableList<SubmissionListener> submissionListeners;
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
+  public SubmissionExecutor(
+      BatchUpdates batchUpdates,
+      boolean dryrun,
+      ImmutableList<SubmissionListener> submissionListeners) {
+    this.batchUpdates = batchUpdates;
     this.dryrun = dryrun;
     this.submissionListeners = submissionListeners;
     if (dryrun) {
@@ -58,7 +63,7 @@
                     .map(Optional::get)
                     .collect(Collectors.toList()))
             .build();
-    BatchUpdate.execute(updates, listeners, dryrun);
+    batchUpdates.execute(updates, listeners, dryrun);
   }
 
   /**
diff --git a/java/com/google/gerrit/sshd/InvalidKeyAlgorithmException.java b/java/com/google/gerrit/sshd/InvalidKeyAlgorithmException.java
new file mode 100644
index 0000000..5f09658
--- /dev/null
+++ b/java/com/google/gerrit/sshd/InvalidKeyAlgorithmException.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import java.security.PublicKey;
+import java.security.spec.InvalidKeySpecException;
+
+public class InvalidKeyAlgorithmException extends InvalidKeySpecException {
+  private final String invalidKeyAlgo;
+  private final String expectedKeyAlgo;
+  private final PublicKey publicKey;
+
+  public InvalidKeyAlgorithmException(
+      String invalidKeyAlgo, String expectedKeyAlgo, PublicKey publicKey) {
+    super("Key algorithm mismatch: expected " + expectedKeyAlgo + " but got " + invalidKeyAlgo);
+    this.invalidKeyAlgo = invalidKeyAlgo;
+    this.expectedKeyAlgo = expectedKeyAlgo;
+    this.publicKey = publicKey;
+  }
+
+  public String getInvalidKeyAlgo() {
+    return invalidKeyAlgo;
+  }
+
+  public String getExpectedKeyAlgo() {
+    return expectedKeyAlgo;
+  }
+
+  public PublicKey getPublicKey() {
+    return publicKey;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 628a050..ff452a6 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -19,6 +19,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -144,6 +145,15 @@
         // to do with the key object, and instead we must abort this load.
         //
         throw e;
+      } catch (InvalidKeyAlgorithmException e) {
+        logger.atWarning().withCause(e).log(
+            "SSH key %d of account %s has an invalid algorithm %s: fixing the algorithm to %s",
+            k.seq(), k.accountId(), e.getInvalidKeyAlgo(), e.getExpectedKeyAlgo());
+        if (fixKeyAlgorithm(k, e.getExpectedKeyAlgo())) {
+          kl.add(new SshKeyCacheEntry(k.accountId(), e.getPublicKey()));
+        } else {
+          markInvalid(k);
+        }
       } catch (Exception e) {
         markInvalid(k);
       }
@@ -158,5 +168,20 @@
             "Failed to mark SSH key %d of account %s invalid", k.seq(), k.accountId());
       }
     }
+
+    private boolean fixKeyAlgorithm(AccountSshKey k, String keyAlgo) {
+      try {
+        logger.atInfo().log(
+            "Fixing SSH key %d of account %s algorithm to %s", k.seq(), k.accountId(), keyAlgo);
+        authorizedKeys.deleteKey(k.accountId(), k.seq());
+        String sshKey = k.sshPublicKey();
+        authorizedKeys.addKey(k.accountId(), keyAlgo + sshKey.substring(sshKey.indexOf(' ')));
+        return true;
+      } catch (IOException | ConfigInvalidException | InvalidSshKeyException e) {
+        logger.atSevere().withCause(e).log(
+            "Failed to fix SSH key %d of account %s with algo %s", k.seq(), k.accountId(), keyAlgo);
+        return false;
+      }
+    }
   }
 }
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index abbd81d..29d0e90 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -57,7 +57,12 @@
         throw new InvalidKeySpecException("No key string");
       }
       final byte[] bin = BaseEncoding.base64().decode(s);
-      return new ByteArrayBuffer(bin).getRawPublicKey();
+      String publicKeyAlgo = new ByteArrayBuffer(bin).getString();
+      PublicKey publicKey = new ByteArrayBuffer(bin).getRawPublicKey();
+      if (!key.algorithm().equals(publicKeyAlgo)) {
+        throw new InvalidKeyAlgorithmException(key.algorithm(), publicKeyAlgo, publicKey);
+      }
+      return publicKey;
     } catch (RuntimeException | SshException e) {
       throw new InvalidKeySpecException("Cannot parse key", e);
     }
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index fd18656..9c33201 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -74,7 +74,7 @@
       Optional<InternalGroup> group = groupCache.get(AccountGroup.nameKey(name));
       String errorText = "Group not found or not visible\n";
 
-      if (!group.isPresent()) {
+      if (!group.isPresent() || !canSeeGroup(group.get())) {
         writer.write(errorText);
         writer.flush();
         return;
diff --git a/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
index 0119349b..d478a9d 100644
--- a/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.server.plugins.PluginInstallException;
 import com.google.gerrit.sshd.CommandMetaData;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
@@ -29,7 +30,11 @@
   @Override
   protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
-      loader.disablePlugins(Sets.newHashSet(names));
+      try {
+        loader.disablePlugins(Sets.newHashSet(names));
+      } catch (PluginInstallException e) {
+        throw die(e);
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index b8c8406..95f771c 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -321,11 +321,11 @@
         AbandonInput input = new AbandonInput();
         input.message = Strings.emptyToNull(changeComment);
         applyReview(patchSet, review);
-        changeApi(patchSet).abandon(input);
+        getChangeApi(patchSet).abandon(input);
       } else if (restoreChange) {
         RestoreInput input = new RestoreInput();
         input.message = Strings.emptyToNull(changeComment);
-        changeApi(patchSet).restore(input);
+        getChangeApi(patchSet).restore(input);
         applyReview(patchSet, review);
       } else {
         applyReview(patchSet, review);
@@ -335,15 +335,15 @@
         MoveInput moveInput = new MoveInput();
         moveInput.destinationBranch = moveToBranch;
         moveInput.message = Strings.emptyToNull(changeComment);
-        changeApi(patchSet).move(moveInput);
+        getChangeApi(patchSet).move(moveInput);
       }
 
       if (rebaseChange) {
-        revisionApi(patchSet).rebase();
+        getRevisionApi(patchSet).rebase();
       }
 
       if (submitChange) {
-        revisionApi(patchSet).submit();
+        getRevisionApi(patchSet).submit();
       }
 
     } catch (IllegalStateException | RestApiException e) {
@@ -351,12 +351,19 @@
     }
   }
 
-  private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
-    return gApi.changes().id(patchSet.id().changeId().get());
+  private ChangeApi getChangeApi(PatchSet patchSet) throws RestApiException {
+    if (projectState != null) {
+      return gApi.changes().id(projectState.getName(), patchSet.id().changeId().get());
+    }
+    /* Since we didn't get a project from the CLI we have to use the ambiguous
+     * Changes#id(String) that may fail to identify one single change and throw
+     * an exception.
+     */
+    return gApi.changes().id(String.valueOf(patchSet.id().changeId().get()));
   }
 
-  private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
-    return changeApi(patchSet).revision(patchSet.commitId().name());
+  private RevisionApi getRevisionApi(PatchSet patchSet) throws RestApiException {
+    return getChangeApi(patchSet).revision(patchSet.commitId().name());
   }
 
   @Override
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 712715e..8bdf6fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -129,7 +130,10 @@
         err.append("error: insuffient access rights to change parent of '")
             .append(name)
             .append("'\n");
-      } catch (ResourceConflictException | ResourceNotFoundException | BadRequestException e) {
+      } catch (ResourceConflictException
+          | ResourceNotFoundException
+          | BadRequestException
+          | MethodNotAllowedException e) {
         err.append("error: ").append(e.getMessage()).append("'\n");
       } catch (UnprocessableEntityException | IOException e) {
         throw new Failure(1, "failure in request", e);
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 00361ad..14915bf 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -134,6 +134,7 @@
       switch (task.state) {
         case DONE:
         case CANCELLED:
+        case PARKED:
         case STARTING:
         case RUNNING:
         case STOPPING:
@@ -212,6 +213,8 @@
         return "";
       case STARTING:
         return "starting ...";
+      case PARKED:
+        return "parked .....";
       case READY:
         return "waiting ....";
       case SLEEPING:
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index 3101c48..67134de 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -244,7 +244,7 @@
     try {
       result.add(
           new ConfigRunner(
-              clazz, parameterField, nameField, null, callConfigMethod(defaultConfig)));
+              clazz, parameterField, nameField, DEFAULT, callConfigMethod(defaultConfig)));
       for (Method m : configs) {
         result.add(
             new ConfigRunner(clazz, parameterField, nameField, m.getName(), callConfigMethod(m)));
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index 2c01548..330b602 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -43,6 +43,7 @@
     return newState(
         Account.builder(accountId, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
+            .setUniqueTag("1234567812345678123456781234567812345678")
             .build());
   }
 
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b0045e3..789655f 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.Sequence.LightweightGroups;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
 import com.google.gerrit.server.api.GerritApiModule;
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -102,6 +104,7 @@
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
+import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
 import com.google.gerrit.server.notedb.NoteDbDraftCommentsModule;
 import com.google.gerrit.server.notedb.NoteDbStarredChangesModule;
 import com.google.gerrit.server.notedb.RepoSequence.DisabledGitRefUpdatedRepoGroupsSequenceProvider;
@@ -109,7 +112,7 @@
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.ServerInformationImpl;
-import com.google.gerrit.server.project.DefaultProjectNameLockManager.DefaultProjectNameLockManagerModule;
+import com.google.gerrit.server.project.DefaultLockManager.DefaultLockManagerModule;
 import com.google.gerrit.server.restapi.RestApiModule;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaCreator;
@@ -206,9 +209,13 @@
     install(cfgInjector.getInstance(GerritGlobalModule.class));
     install(new AccountNoteDbWriteStorageModule());
     install(new AccountNoteDbReadStorageModule());
+    install(new ExternalIdCacheImpl.ExternalIdCacheModule());
+    install(new ExternalIdCacheImpl.ExternalIdCacheBindingModule());
     install(new RepoSequenceModule());
+    install(new FromAddressGeneratorProvider.UserAddressGenModule());
     install(new NoteDbDraftCommentsModule());
     install(new NoteDbStarredChangesModule());
+    install(new ChangeCleanupRunnerModule());
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
@@ -295,7 +302,7 @@
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
     install(new OAuthRestModule());
-    install(new DefaultProjectNameLockManagerModule());
+    install(new DefaultLockManagerModule());
     install(new FileInfoJsonModule());
     install(new ConfigExperimentFeaturesModule());
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index cc0306c..55a2023 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
@@ -52,6 +53,7 @@
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
+import com.github.rholder.retry.RetryException;
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -112,11 +114,13 @@
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountStateInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.MetadataInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -132,18 +136,22 @@
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.server.ExceptionHook;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountStateProvider;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
@@ -157,9 +165,11 @@
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.restapi.account.GetCapabilities;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
@@ -256,12 +266,14 @@
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
-  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactory;
+  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactoryNoteDbImpl;
   @Inject private AuthConfig authConfig;
   @Inject private AccountControl.Factory accountControlFactory;
   @Inject private AccountOperations accountOperations;
-
+  @Inject private AccountLimits.Factory limitsFactory;
   @Inject private AccountPatchReviewStore accountPatchReviewStore;
+  @Inject private IdentifiedUser.GenericFactory genericUserFactory;
+  @Inject private PermissionBackend permissionBackend;
 
   private BasicCookieStore httpCookieStore;
   private CloseableHttpClient httpclient;
@@ -327,7 +339,7 @@
 
   @Test
   public void createByAccountCreator() throws Exception {
-    RefUpdateCounter refUpdateCounter = new RefUpdateCounter(server.isRefSequenceSupported());
+    RefUpdateCounter refUpdateCounter = createRefUpdateCounter();
     try (Registration registration = extensionRegistry.newRegistration().add(refUpdateCounter)) {
       Account.Id accountId = createByAccountCreator(1);
       refUpdateCounter.assertRefUpdateFor(
@@ -409,8 +421,8 @@
       accountIndexedCounter.assertReindexOf(accountId, 1);
       assertThat(getExternalIdsReader().byAccount(accountId))
           .containsExactly(
-              externalIdFactory.createUsername(input.username, accountId, null),
-              externalIdFactory.createEmail(accountId, input.email));
+              getExternalIdFactory().createUsername(input.username, accountId, null),
+              getExternalIdFactory().createEmail(accountId, input.email));
     }
   }
 
@@ -463,7 +475,7 @@
   public void createAtomically() throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
     String fullName = "Foo";
-    ExternalId extId = externalIdFactory.createEmail(accountId, "foo@example.com");
+    ExternalId extId = getExternalIdFactory().createEmail(accountId, "foo@example.com");
     AccountState accountState =
         accountsUpdateProvider
             .get()
@@ -631,7 +643,9 @@
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       Account.Id activatableAccountId =
           accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
-      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+      // Implementation of the accountOperations can create an account in several steps,
+      // with more than one reindexing.
+      accountIndexedCounter.assertReindexAtLeastOnceOf(activatableAccountId);
     }
 
     @SuppressWarnings("unused")
@@ -645,7 +659,9 @@
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       Account.Id activatableAccountId =
           accountOperations.newAccount().inactive().username("foo").create();
-      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+      // Implementation of the accountOperations can create an account in several steps,
+      // with more than one reindexing.
+      accountIndexedCounter.assertReindexAtLeastOnceOf(activatableAccountId);
     }
 
     @SuppressWarnings("unused")
@@ -805,7 +821,7 @@
   @Test
   public void starUnstarChange() throws Exception {
     AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
-    RefUpdateCounter refUpdateCounter = new RefUpdateCounter(server.isRefSequenceSupported());
+    RefUpdateCounter refUpdateCounter = createRefUpdateCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter).add(refUpdateCounter)) {
       PushOneCommit.Result r = createChange();
@@ -1275,11 +1291,13 @@
               admin.id(),
               u ->
                   u.addExternalId(
-                          externalIdFactory.createWithEmail(
-                              externalIdKeyFactory.parse(extId1), admin.id(), email))
+                          getExternalIdFactory()
+                              .createWithEmail(
+                                  externalIdKeyFactory.parse(extId1), admin.id(), email))
                       .addExternalId(
-                          externalIdFactory.createWithEmail(
-                              externalIdKeyFactory.parse(extId2), admin.id(), email)));
+                          getExternalIdFactory()
+                              .createWithEmail(
+                                  externalIdKeyFactory.parse(extId2), admin.id(), email)));
       accountIndexedCounter.assertReindexOf(admin);
       assertThat(
               gApi.accounts().self().getExternalIds().stream()
@@ -1315,8 +1333,9 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    externalIdFactory.createWithEmail(
-                        externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
+                    getExternalIdFactory()
+                        .createWithEmail(
+                            externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream()
                 .map(e -> e.identity)
@@ -1354,13 +1373,17 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                        externalIdFactory.createWithEmail(
-                            externalIdKeyFactory.parse(nonLdapExternalId),
-                            admin.id(),
-                            nonLdapEMail))
+                        getExternalIdFactory()
+                            .createWithEmail(
+                                externalIdKeyFactory.parse(nonLdapExternalId),
+                                admin.id(),
+                                nonLdapEMail))
                     .addExternalId(
-                        externalIdFactory.createWithEmail(
-                            externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
+                        getExternalIdFactory()
+                            .createWithEmail(
+                                externalIdKeyFactory.parse(ldapExternalId),
+                                admin.id(),
+                                ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAtLeast(ldapExternalId, nonLdapExternalId);
@@ -1423,8 +1446,9 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    externalIdFactory.createWithEmail(
-                        externalIdKeyFactory.parse("foo:bar"), admin.id(), email)));
+                    getExternalIdFactory()
+                        .createWithEmail(
+                            externalIdKeyFactory.parse("foo:bar"), admin.id(), email)));
     assertEmail(emails.getAccountFor(email), admin);
 
     // wrong case doesn't match
@@ -1740,7 +1764,7 @@
           .update(
               "Add External ID",
               user.id(),
-              u -> u.addExternalId(externalIdFactory.create("foo", "myId", user.id())));
+              u -> u.addExternalId(getExternalIdFactory().create("foo", "myId", user.id())));
       accountIndexedCounter.assertReindexOf(user);
 
       TestKey key = validKeyWithSecondUserId();
@@ -2043,7 +2067,7 @@
         .update(
             "Delete External ID",
             account.id(),
-            u -> u.deleteExternalId(externalIdFactory.createEmail(account.id(), email)));
+            u -> u.deleteExternalId(getExternalIdFactory().createEmail(account.id(), email)));
     expectedProblems.add(
         new ConsistencyProblemInfo(
             ConsistencyProblemInfo.Status.ERROR,
@@ -2074,23 +2098,30 @@
   }
 
   @Test
-  public void checkMetaId() throws Exception {
-    // metaId is set when account is loaded
+  public void checkMetaIdAndUniqueTag() throws Exception {
+    // In open-source Gerrit, the uniqueTag and metaId are always the same. Check them together
+    // in this test.
+    // metaId and uniqueTag are set when account is loaded
     assertThat(accounts.get(admin.id()).get().account().metaId()).isEqualTo(getMetaId(admin.id()));
+    assertThat(accounts.get(admin.id()).get().account().uniqueTag())
+        .isEqualTo(getMetaId(admin.id()));
 
-    // metaId is set when account is created
+    // metaId and uniqueTag are set when account is created
     AccountsUpdate au = accountsUpdateProvider.get();
     Account.Id accountId = Account.id(seq.nextAccountId());
     AccountState accountState = au.insert("Create Test Account", accountId, u -> {});
     assertThat(accountState.account().metaId()).isEqualTo(getMetaId(accountId));
+    assertThat(accountState.account().uniqueTag()).isEqualTo(getMetaId(accountId));
 
-    // metaId is set when account is updated
+    // metaId and uniqueTag are set when account is updated
     Optional<AccountState> updatedAccountState =
         au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
     assertThat(updatedAccountState).isPresent();
     Account updatedAccount = updatedAccountState.get().account();
     assertThat(accountState.account().metaId()).isNotEqualTo(updatedAccount.metaId());
+    assertThat(accountState.account().uniqueTag()).isNotEqualTo(updatedAccount.uniqueTag());
     assertThat(updatedAccount.metaId()).isEqualTo(getMetaId(accountId));
+    assertThat(updatedAccount.uniqueTag()).isEqualTo(getMetaId(accountId));
   }
 
   private EmailInput newEmailInput(String email, boolean noConfirmation) {
@@ -2268,9 +2299,12 @@
     assertThat(accountInfo.status).isNull();
     assertThat(accountInfo.name).isNotEqualTo(fullName);
 
-    assertThrows(
-        LockFailureException.class,
-        () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
+    StorageException exception =
+        assertThrows(
+            StorageException.class,
+            () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
+    assertThat(exception).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(exception.getCause()).hasCauseThat().isInstanceOf(LockFailureException.class);
     assertThat(bgCounter.get()).isEqualTo(status.size());
 
     Account updatedAccount = accounts.get(admin.id()).get().account();
@@ -2293,7 +2327,7 @@
             () -> {
               if (bgIndicatorA1ToA2.get()) {
                 // In the Google architecture, this runnable might be called multiple times. Only
-                // do the replacement ones.
+                // do the replacement once.
                 return;
               }
               try {
@@ -2303,6 +2337,7 @@
               } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
+              bgIndicatorA1ToA2.set(true);
             });
 
     assertThat(gApi.accounts().id(admin.id().get()).get().status).isEqualTo("A-1");
@@ -2342,12 +2377,12 @@
         .update();
 
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId extIdA1 = externalIdFactory.create("foo", "A-1", accountId);
+    ExternalId extIdA1 = getExternalIdFactory().create("foo", "A-1", accountId);
     accountsUpdateProvider
         .get()
         .insert("Create Test Account", accountId, u -> u.addExternalId(extIdA1));
 
-    ExternalId extIdA2 = externalIdFactory.create("foo", "A-2", accountId);
+    ExternalId extIdA2 = getExternalIdFactory().create("foo", "A-2", accountId);
     AtomicBoolean bgIndicatorA1ToA2 = new AtomicBoolean(false);
     AccountsUpdate update =
         getAccountsUpdateWithRunnables(
@@ -2355,7 +2390,7 @@
             () -> {
               if (bgIndicatorA1ToA2.get()) {
                 // In the Google architecture, this runnable might be called multiple times. Only
-                // do the replacement ones.
+                // do the replacement once.
                 return;
               }
               try {
@@ -2365,7 +2400,6 @@
                         "Update External ID A1->A2",
                         accountId,
                         u -> u.replaceExternalId(extIdA1, extIdA2));
-                bgIndicatorA1ToA2.set(true);
               } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
@@ -2378,8 +2412,8 @@
                 .collect(toSet()))
         .containsExactly(extIdA1.key().get());
 
-    ExternalId extIdB1 = externalIdFactory.create("foo", "B-1", accountId);
-    ExternalId extIdB2 = externalIdFactory.create("foo", "B-2", accountId);
+    ExternalId extIdB1 = getExternalIdFactory().create("foo", "B-1", accountId);
+    ExternalId extIdB2 = getExternalIdFactory().create("foo", "B-2", accountId);
     AtomicBoolean bgIndicatorA1ToB1 = new AtomicBoolean(false);
     AtomicBoolean bgIndicatorA2ToB2 = new AtomicBoolean(false);
     Optional<AccountState> updatedAccount =
@@ -2449,38 +2483,24 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       testRefAction(
           () -> {
-            ExternalIdNotes extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            ExternalIdNotes extIdNotes = getExternalIdNotes(repo);
 
             ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
-            extIdNotes.insert(externalIdFactory.create(key, accountId));
+            extIdNotes.insert(getExternalIdFactory().create(key, accountId));
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
             }
             assertStaleAccountAndReindex(accountId);
 
-            extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
-            extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
+            extIdNotes = getExternalIdNotes(repo);
+            extIdNotes.upsert(
+                getExternalIdFactory().createWithEmail(key, accountId, "foo@example.com"));
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
             }
             assertStaleAccountAndReindex(accountId);
 
-            extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            extIdNotes = getExternalIdNotes(repo);
             extIdNotes.delete(accountId, key);
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
@@ -2745,23 +2765,19 @@
     String extId1String = "foo:bar";
     String extId2String = "foo:baz";
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
 
-    ObjectId revBefore;
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      revBefore = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
-    }
-
+    int initialCommits = countExternalIdsCommits();
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+            "Add External ID", user.id(), u -> u.addExternalId(extId2));
     ImmutableList<Optional<AccountState>> accountStates =
         accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
     assertThat(accountStates).hasSize(2);
@@ -2779,29 +2795,36 @@
         .contains(extId2String);
 
     // Ensure that we only applied one single commit.
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit after = rw.parseCommit(repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
-      assertThat(after.getParent(0).toObjectId()).isEqualTo(revBefore);
+    int afterUpdateCommits = countExternalIdsCommits();
+    assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int countExternalIdsCommits() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        Git git = new Git(allUsersRepo)) {
+      ObjectId refsMetaExternalIdsHead =
+          allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+      return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
     }
   }
 
   @Test
   public void externalIdBatchUpdates_fail_sameAccount() {
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
     // Another update for the same account is not allowed.
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId2));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId2));
     IllegalArgumentException e =
         assertThrows(
             IllegalArgumentException.class,
@@ -2812,18 +2835,18 @@
   @Test
   public void externalIdBatchUpdates_fail_duplicateKey() {
     ExternalId extIdAdmin =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extIdUser =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extIdAdmin));
+            "Add External ID", admin.id(), u -> u.addExternalId(extIdAdmin));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), (a, u) -> u.addExternalId(extIdUser));
+            "Add External ID", user.id(), u -> u.addExternalId(extIdUser));
     DuplicateExternalIdKeyException e =
         assertThrows(
             DuplicateExternalIdKeyException.class,
@@ -2834,18 +2857,18 @@
   @Test
   public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "first message", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "first message", admin.id(), u -> u.addExternalId(extId1));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "second message", user.id(), (a, u) -> u.addExternalId(extId2));
+            "second message", user.id(), u -> u.addExternalId(extId2));
     accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
 
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
@@ -2860,10 +2883,10 @@
   @Test
   public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
     ExternalId extId =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
 
-    accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
+    accountsUpdateProvider.get().update("foobar", admin.id(), u -> u.addExternalId(extId));
 
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(allUsersRepo)) {
@@ -2980,10 +3003,10 @@
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
 
-    ExternalId externalId = externalIdFactory.create("custom", "value", admin.id());
+    ExternalId externalId = getExternalIdFactory().create("custom", "value", admin.id());
     accountsUpdateProvider
         .get()
-        .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
+        .update("Add External ID", admin.id(), u -> u.addExternalId(externalId));
     assertExternalIds(
         admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
 
@@ -2999,7 +3022,7 @@
     ExternalId externalId = createEmailExternalId(admin.id(), "admin@example.com");
     accountsUpdateProvider
         .get()
-        .update("Remove External ID", admin.id(), (a, u) -> u.deleteExternalId(externalId));
+        .update("Remove External ID", admin.id(), u -> u.deleteExternalId(externalId));
     assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
 
     AccountState updatedState = accountCache.get(admin.id()).get();
@@ -3012,11 +3035,12 @@
     requestScopeOperations.setApiUser(admin.id());
 
     ExternalId externalId =
-        externalIdFactory.createWithEmail(
-            SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
     accountsUpdateProvider
         .get()
-        .update("Update External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
+        .update("Update External ID", admin.id(), u -> u.updateExternalId(externalId));
     assertExternalIds(
         admin.id(),
         ImmutableSet.of(
@@ -3032,23 +3056,26 @@
     requestScopeOperations.setApiUser(admin.id());
 
     ExternalId externalId =
-        externalIdFactory.createWithEmail(
-            SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+    ExternalId oldExternalId =
+        getExternalIdsReader().get(createEmailExternalId(admin.id(), admin.email()).key()).get();
     accountsUpdateProvider
         .get()
         .update(
-            "Replace External ID",
-            admin.id(),
-            (a, u) ->
-                u.replaceExternalId(
-                    getExternalIdsReader()
-                        .get(createEmailExternalId(admin.id(), admin.email()).key())
-                        .get(),
-                    externalId));
+            "Replace External ID", admin.id(), u -> u.replaceExternalId(oldExternalId, externalId));
     assertExternalIds(admin.id(), ImmutableSet.of("mailto:secondary@non.google", "username:admin"));
 
     AccountState updatedState = accountCache.get(admin.id()).get();
-    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+    assertThat(accountCache.get(admin.id()).get()).isNotSameInstanceAs(preUpdateState);
+    if (preUpdateState.account().metaId() == null) {
+      // When the test is executed on google infrastructure, metaId should be either always set
+      // or always be null.
+      assertThat(updatedState.account().metaId()).isNull();
+    } else {
+      assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+    }
   }
 
   @Test
@@ -3059,17 +3086,18 @@
 
     requestScopeOperations.setApiUser(admin.id());
     ExternalId extId1 =
-        externalIdFactory.createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
+        getExternalIdFactory()
+            .createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
 
     ExternalId extId2 =
-        externalIdFactory.createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
+        getExternalIdFactory().createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+            "Add External ID", user.id(), u -> u.addExternalId(extId2));
     AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
@@ -3106,12 +3134,12 @@
     requestScopeOperations.setApiUser(admin.id());
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
-            "Update Display Name", admin.id(), (a, u) -> u.setDisplayName("DN"));
+            "Update Display Name", admin.id(), u -> u.setDisplayName("DN"));
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
             "Remove external Id",
             user.id(),
-            (a, u) -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
+            u -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
     AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
@@ -3129,8 +3157,28 @@
         .isNotEqualTo(updatedUserState.account().metaId());
   }
 
+  @Test
+  public void accountUpdate_emptyStringsToUnset() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Replace External ID",
+            admin.id(),
+            u -> u.setFullName("").setDisplayName("").setPreferredEmail("").setStatus(""));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(accountCache.get(admin.id()).get()).isNotSameInstanceAs(preUpdateState);
+    assertThat(updatedState.account().fullName()).isNull();
+    assertThat(updatedState.account().displayName()).isNull();
+    assertThat(updatedState.account().preferredEmail()).isNull();
+    assertThat(updatedState.account().status()).isNull();
+  }
+
   protected ExternalId createEmailExternalId(Account.Id accountId, String email) {
-    return externalIdFactory.createWithEmail(SCHEME_MAILTO, email, accountId, email);
+    return getExternalIdFactory().createWithEmail(SCHEME_MAILTO, email, accountId, email);
   }
 
   @Test
@@ -3315,23 +3363,12 @@
     requestScopeOperations.setApiUser(deleted.id());
 
     gApi.accounts().self().starChange(triplet);
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
-          .hasSize(1);
+    assertThat(getStarredChangesCount(r.getChange().getId())).isEqualTo(1);
 
-      gApi.accounts().self().delete();
-    }
+    gApi.accounts().self().delete();
 
     // Reopen the repo to refresh RefDb
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
-          .isEmpty();
-    }
-
+    assertThat(getStarredChangesCount(r.getChange().getId())).isEqualTo(0);
     // Clean up the test framework
     accountCreator.evict(deleted.id());
   }
@@ -3373,27 +3410,35 @@
     requestScopeOperations.setApiUser(deleted.id());
 
     createDraft(r, PushOneCommit.FILE_NAME, "draft");
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
-          .hasSize(1);
+    assertThat(getUsersWithDraftsCount(r.getChange().getId())).isEqualTo(1);
+    gApi.accounts().self().delete();
 
-      gApi.accounts().self().delete();
-    }
-
-    // Reopen the repo to refresh RefDb
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
-          .isEmpty();
-    }
+    assertThat(getUsersWithDraftsCount(r.getChange().getId())).isEqualTo(0);
 
     // Clean up the test framework
     accountCreator.evict(deleted.id());
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int getUsersWithDraftsCount(Change.Id changeId) throws Exception {
+    // The getStarredChangesCount and getUsersWithDraftsCount should be 2 distinct methods,
+    // because in google they can query data from a different storage (i.e. not from noteDb).
+    return getRefCount(RefNames.refsDraftCommentsPrefix(changeId));
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int getStarredChangesCount(Change.Id changeId) throws Exception {
+    // The getStarredChangesCount and getDraftsCommentsCount should be 2 distinct methods,
+    // because in google they can query data from a different storage (i.e. not from noteDb).
+    return getRefCount(RefNames.refsStarredChangesPrefix(changeId));
+  }
+
+  private int getRefCount(String refPrefix) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return repo.getRefDatabase().getRefsByPrefix(refPrefix).size();
+    }
+  }
+
   @Test
   @SuppressWarnings("unused")
   public void deleteAccount_deletesReviewedFlags() throws Exception {
@@ -3437,6 +3482,96 @@
     assertThat(thrown).hasMessageThat().isEqualTo("Delete account is only permitted for self");
   }
 
+  @Test
+  public void getOwnAccountState() throws Exception {
+    String email = "preferred@example.com";
+    String name = "Foo";
+    String username = name("foo");
+    TestAccount foo = accountCreator.create(username, email, name, null);
+    String secondaryEmail = "secondary@non.google";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
+
+    String status = "OOO";
+    gApi.accounts().id(foo.id().get()).setStatus(status);
+
+    String groupName = "SomeGroup";
+    groupOperations.newGroup().name(groupName).addMember(foo.id()).create();
+
+    TestAccountStateProvider testAccountStateProvider = new TestAccountStateProvider();
+    MetadataInfo metadata1 = testAccountStateProvider.addMetadata("employee_id", "123456", null);
+    MetadataInfo metadata2 = testAccountStateProvider.addMetadata("role", null, "role name");
+    MetadataInfo metadata3 = testAccountStateProvider.addMetadata("team", "Bar", "team name");
+    MetadataInfo metadata4 = testAccountStateProvider.addMetadata("team", "Foo", "team name");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testAccountStateProvider)) {
+      requestScopeOperations.setApiUser(foo.id());
+      AccountStateInfo state = gApi.accounts().id(foo.id().get()).state();
+
+      AccountDetailInfo detail = state.account;
+      assertThat(detail._accountId).isEqualTo(foo.id().get());
+      assertThat(detail.name).isEqualTo(name);
+      if (server.isUsernameSupported()) {
+        assertThat(detail.username).isEqualTo(username);
+      }
+      assertThat(detail.email).isEqualTo(email);
+      assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
+      assertThat(detail.status).isEqualTo(status);
+      assertThat(detail.registeredOn.getTime())
+          .isEqualTo(getAccount(foo.id()).registeredOn().toEpochMilli());
+      assertThat(detail.inactive).isNull();
+      assertThat(detail._moreAccounts).isNull();
+
+      if (permissionBackend.usesDefaultCapabilities()) {
+        AccountLimits limits = limitsFactory.create(genericUserFactory.create(foo.id()));
+        GetCapabilities.Range queryLimitRange =
+            new GetCapabilities.Range(limits.getRange("queryLimit"));
+        assertThat(state.capabilities)
+            .containsExactly("emailReviewers", true, "queryLimit", queryLimitRange);
+      } else {
+        assertThat(state.capabilities).isNull();
+      }
+
+      assertThat(state.groups)
+          .comparingElementsUsing(getGroupToNameCorrespondence())
+          .containsAtLeast("Anonymous Users", "Registered Users", groupName);
+
+      assertExternalIds(
+          state.externalIds.stream().map(e -> e.identity).collect(toImmutableSet()),
+          ImmutableSet.of("mailto:" + email, "username:" + username, "mailto:" + secondaryEmail));
+
+      // Using containsAtLeast instead of containsExcatly because when the test is run internally at
+      // Google additional metadata is returned.
+      assertThat(state.metadata)
+          .containsAtLeast(metadata1, metadata2, metadata3, metadata4)
+          .inOrder();
+    }
+  }
+
+  @Test
+  public void nonAdminCannotGetAccountStateOfOtherUser() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(admin.id().get()).state());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("cannot get account state of other user: administrate server not permitted");
+  }
+
+  @Test
+  public void adminCanGetAccountStateOfOtherUser() throws Exception {
+    AccountStateInfo state = gApi.accounts().id(user.id().get()).state();
+    assertThat(state.account._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void getAccountStateRequiresAuthentication() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(user.id().get()).state());
+    assertThat(thrown).hasMessageThat().isEqualTo("Authentication required");
+  }
+
   private TestGroupBackend createTestGroupBackendWithAllUsersGroup(String nameOfAllUsersGroup)
       throws IOException {
     TestGroupBackend testGroupBackend = new TestGroupBackend();
@@ -3485,13 +3620,19 @@
     return testGroupBackend;
   }
 
-  protected void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
+  private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
       throws Exception {
-    assertThat(
-            gApi.accounts().id(accountId.get()).getExternalIds().stream()
-                .map(e -> e.identity)
-                .collect(toImmutableSet()))
-        .isEqualTo(extIds);
+    assertExternalIds(
+        gApi.accounts().id(accountId.get()).getExternalIds().stream()
+            .map(e -> e.identity)
+            .collect(toImmutableSet()),
+        extIds);
+  }
+
+  protected void assertExternalIds(
+      ImmutableSet<String> actualExternalIds, ImmutableSet<String> expectedExternalIds)
+      throws Exception {
+    assertThat(actualExternalIds).isEqualTo(expectedExternalIds);
   }
 
   private void assertExternalEmails(Account.Id accountId, ImmutableSet<String> extIds)
@@ -3614,7 +3755,8 @@
               account.id(),
               u ->
                   u.addExternalId(
-                      externalIdFactory.createWithEmail(name("test"), email, account.id(), email)));
+                      getExternalIdFactory()
+                          .createWithEmail(name("test"), email, account.id(), email)));
       accountIndexedCounter.assertReindexOf(account);
       requestScopeOperations.setApiUser(account.id());
     }
@@ -3709,6 +3851,20 @@
             r -> r.withBlockStrategy(noSleepBlockStrategy)));
   }
 
+  private ExternalIdNotes getExternalIdNotes(Repository allUsersRepo)
+      throws ConfigInvalidException, IOException {
+    return ExternalIdNotes.load(
+        allUsers,
+        allUsersRepo,
+        externalIdFactoryNoteDbImpl,
+        authConfig.isUserNameCaseInsensitiveMigrationMode());
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected ExternalIdFactory getExternalIdFactory() {
+    return externalIdFactoryNoteDbImpl;
+  }
+
   @UsedAt(UsedAt.Project.GOOGLE)
   protected ExternalIds getExternalIdsReader() {
     return externalIdsNoteDbImpl;
@@ -3745,13 +3901,13 @@
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
-  public static class RefUpdateCounter implements GitReferenceUpdatedListener {
-    private final boolean refSequenceSupported;
-    private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
+  protected RefUpdateCounter createRefUpdateCounter() {
+    return new RefUpdateCounter();
+  }
 
-    public RefUpdateCounter(boolean refSequenceSupported) {
-      this.refSequenceSupported = refSequenceSupported;
-    }
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static class RefUpdateCounter implements GitReferenceUpdatedListener {
+    private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
 
     @UsedAt(UsedAt.Project.GOOGLE)
     public static String projectRef(Project.NameKey project, String ref) {
@@ -3782,14 +3938,35 @@
 
     protected void assertRefUpdateFor(Map<String, Long> expectedProjectRefUpdateCounts) {
       ImmutableMap<String, Long> exprectedFiltered =
-          refSequenceSupported
-              ? ImmutableMap.copyOf(expectedProjectRefUpdateCounts)
-              : ImmutableMap.copyOf(
-                  expectedProjectRefUpdateCounts.entrySet().stream()
-                      .filter(entry -> !entry.getKey().contains(":refs/sequences/"))
-                      .collect(toList()));
+          expectedProjectRefUpdateCounts.entrySet().stream()
+              .filter(entry -> isRefSupported(entry.getKey()))
+              .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
       assertThat(countsByProjectRefs.asMap()).containsExactlyEntriesIn(exprectedFiltered);
       clear();
     }
+
+    @UsedAt(UsedAt.Project.GOOGLE)
+    protected boolean isRefSupported(String expectedRefEntryKey) {
+      return true;
+    }
+  }
+
+  public static class TestAccountStateProvider implements AccountStateProvider {
+    private ArrayList<MetadataInfo> metadataList = new ArrayList<>();
+
+    public MetadataInfo addMetadata(
+        String name, @Nullable String value, @Nullable String description) {
+      MetadataInfo metadata = new MetadataInfo();
+      metadata.name = name;
+      metadata.value = value;
+      metadata.description = description;
+      metadataList.add(metadata);
+      return metadata;
+    }
+
+    @Override
+    public ImmutableList<MetadataInfo> getMetadata(Account.Id accountId) {
+      return ImmutableList.copyOf(metadataList);
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
index 3ead608..b4c4595 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -49,16 +50,17 @@
     @Override
     protected void configure() {
       DynamicSet.bind(binder(), AccountActivationValidationListener.class).to(Validator.class);
+      bind(AccountListenersITValidator.class).to(Validator.class);
       DynamicSet.bind(binder(), AccountActivationListener.class).to(Listener.class);
     }
   }
 
-  Validator validator;
+  AccountListenersITValidator validator;
   Listener listener;
 
   @Before
   public void setUp() {
-    validator = plugin.getSysInjector().getInstance(Validator.class);
+    validator = plugin.getSysInjector().getInstance(AccountListenersITValidator.class);
 
     listener = plugin.getSysInjector().getInstance(Listener.class);
   }
@@ -128,8 +130,21 @@
     listener.assertNoMoreEvents();
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected interface AccountListenersITValidator extends AccountActivationValidationListener {
+    void failActivationValidations();
+
+    void failDeactivationValidations();
+
+    void assertNoMoreEvents();
+
+    void assertActivationValidation(int id);
+
+    void assertDeactivationValidation(int id);
+  }
+
   @Singleton
-  public static class Validator implements AccountActivationValidationListener {
+  public static final class Validator implements AccountListenersITValidator {
     private Integer lastIdActivationValidation;
     private Integer lastIdDeactivationValidation;
     private boolean failActivationValidations;
@@ -153,25 +168,30 @@
       }
     }
 
+    @Override
     public void failActivationValidations() {
       failActivationValidations = true;
     }
 
+    @Override
     public void failDeactivationValidations() {
       failDeactivationValidations = true;
     }
 
-    private void assertNoMoreEvents() {
+    @Override
+    public void assertNoMoreEvents() {
       assertThat(lastIdActivationValidation).isNull();
       assertThat(lastIdDeactivationValidation).isNull();
     }
 
-    private void assertActivationValidation(int id) {
+    @Override
+    public void assertActivationValidation(int id) {
       assertThat(lastIdActivationValidation).isEqualTo(id);
       lastIdActivationValidation = null;
     }
 
-    private void assertDeactivationValidation(int id) {
+    @Override
+    public void assertDeactivationValidation(int id) {
       assertThat(lastIdDeactivationValidation).isEqualTo(id);
       lastIdDeactivationValidation = null;
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 7449a5c..efc7e0f 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.SetInactiveFlag;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -51,11 +53,9 @@
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.util.Providers;
-import java.io.IOException;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -593,7 +593,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown).hasMessageThat().contains("Cannot assign external ID \"username:foo\" to");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
@@ -647,9 +647,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("Cannot assign external ID \"username:foo\" to account");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
@@ -798,29 +796,22 @@
         "Create Test Account",
         accountId,
         u -> u.addExternalId(externalIdFactory.create(mailExtIdKey, accountId)));
-
     accountManager.link(accountId, authRequestFactory.createForEmail(email1));
+    int initialCommits = countExternalIdsCommits();
 
-    int initialCommits;
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        Git git = new Git(allUsersRepo)) {
-      initialCommits = getCommitsInExternalIds(git, allUsersRepo);
+    accountManager.updateLink(accountId, authRequestFactory.createForEmail(email2));
 
-      accountManager.updateLink(accountId, authRequestFactory.createForEmail(email2));
-    }
-    // Reopen the repo again - this is required for git.log() operations (otherwise, git.log()
-    // returns unmodified history on google internal infra).
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        Git git = new Git(allUsersRepo)) {
-      int afterUpdateCommits = getCommitsInExternalIds(git, allUsersRepo);
-      assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
-    }
+    int afterUpdateCommits = countExternalIdsCommits();
+    assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
   }
 
-  private static int getCommitsInExternalIds(Git git, Repository allUsersRepo)
-      throws GitAPIException, IOException {
-    ObjectId refsMetaExternalIdsHead = allUsersRepo.exactRef(REFS_EXTERNAL_IDS).getObjectId();
-    return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int countExternalIdsCommits() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        Git git = new Git(allUsersRepo)) {
+      ObjectId refsMetaExternalIdsHead = allUsersRepo.exactRef(REFS_EXTERNAL_IDS).getObjectId();
+      return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+    }
   }
 
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index ead4c40..f462614 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.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.Comparator.comparing;
@@ -26,6 +28,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 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.AccountGroup;
@@ -33,6 +36,7 @@
 import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -63,6 +67,7 @@
   private ContributorAgreement caAutoVerify;
   private ContributorAgreement caNoAutoVerify;
   @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
@@ -298,6 +303,11 @@
     gApi.changes().id(change.changeId).current().submit(new SubmitInput());
 
     // Revert in excluded project is allowed even when CLA is required but not signed
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
     requestScopeOperations.setApiUser(user.id());
     setUseContributorAgreements(InheritableBoolean.TRUE);
     gApi.changes().id(change.changeId).revert();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
index 6802333..74829a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -60,7 +61,7 @@
 
     EditPreferencesInfo info = gApi.accounts().id(admin.id().get()).setEditPreferences(out);
 
-    assertEditPreferences(info, out);
+    assertPrefs(info, out);
 
     // Partially filled input record
     EditPreferencesInfo in = new EditPreferencesInfo();
@@ -69,24 +70,6 @@
     info = gApi.accounts().id(admin.id().get()).setEditPreferences(in);
 
     out.tabSize = in.tabSize;
-    assertEditPreferences(info, out);
-  }
-
-  private void assertEditPreferences(EditPreferencesInfo out, EditPreferencesInfo in)
-      throws Exception {
-    assertThat(out.lineLength).isEqualTo(in.lineLength);
-    assertThat(out.indentUnit).isEqualTo(in.indentUnit);
-    assertThat(out.tabSize).isEqualTo(in.tabSize);
-    assertThat(out.cursorBlinkRate).isEqualTo(in.cursorBlinkRate);
-    assertThat(out.hideTopMenu).isEqualTo(in.hideTopMenu);
-    assertThat(out.showTabs).isNull();
-    assertThat(out.showWhitespaceErrors).isEqualTo(in.showWhitespaceErrors);
-    assertThat(out.syntaxHighlighting).isNull();
-    assertThat(out.hideLineNumbers).isEqualTo(in.hideLineNumbers);
-    assertThat(out.matchBrackets).isNull();
-    assertThat(out.lineWrapping).isEqualTo(in.lineWrapping);
-    assertThat(out.indentWithTabs).isEqualTo(in.indentWithTabs);
-    assertThat(out.autoCloseBrackets).isEqualTo(in.autoCloseBrackets);
-    assertThat(out.showBase).isEqualTo(in.showBase);
+    assertPrefs(info, out);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 0b28f6f..afa9bca 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -85,6 +85,7 @@
     i.signedOffBy ^= true;
     i.allowBrowserNotifications ^= false;
     i.allowSuggestCodeWhileCommenting ^= false;
+    i.allowAutocompletingComments ^= false;
     i.diffPageSidebar = "plugin-insight";
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
@@ -99,6 +100,7 @@
     assertThat(o.theme).isEqualTo(i.theme);
     assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
     assertThat(o.allowSuggestCodeWhileCommenting).isEqualTo(i.allowSuggestCodeWhileCommenting);
+    assertThat(o.allowAutocompletingComments).isEqualTo(i.allowAutocompletingComments);
     assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar);
     assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
index 0309646..984b32d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -18,6 +18,8 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
@@ -34,12 +36,8 @@
 
   @Test
   public void fromAccountUpdate() throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
-      String sha1 =
-          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
-      assertThat(sha1).isEqualTo(messageId);
-    }
+    String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
+    validateAccountUpdateMessageId(messageId, admin.id());
   }
 
   @Test
@@ -78,4 +76,13 @@
             messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
         .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
   }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected void validateAccountUpdateMessageId(String messageId, Account.Id id) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String sha1 =
+          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index f0f262f..18969ad 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -40,7 +40,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.TestTimeUtil;
@@ -53,7 +53,7 @@
 import org.junit.Test;
 
 public class AbandonIT extends AbstractDaemonTest {
-  @Inject private AbandonUtil abandonUtil;
+  @Inject private ChangeCleanupRunner.Factory cleanupRunner;
   @Inject private ChangeCleanupConfig cleanupConfig;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -140,7 +140,7 @@
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
     assertThat(query("is:abandoned")).isEmpty();
 
-    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    cleanupRunner.create().run();
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
     assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
   }
@@ -182,7 +182,7 @@
     assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
     assertThat(toChangeNumbers(query("-is:mergeable"))).containsExactly(id4);
 
-    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    cleanupRunner.create().run();
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5, id2, id1);
     assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4);
   }
@@ -229,7 +229,7 @@
         assertThrows(BadRequestException.class, () -> query("-is:mergeable"));
     assertThat(thrown).hasMessageThat().contains("operator is not supported");
 
-    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    cleanupRunner.create().run();
     assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5);
     assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4, id2, id1);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index 78361a1..1d2d048 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -56,7 +56,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.restapi.change.ApplyPatchUtil;
+import com.google.gerrit.server.patch.ApplyPatchUtil;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -80,6 +80,18 @@
           + "+Second added line\n"
           + "\\ No newline at end of file\n";
 
+  private static final String CONFLICTING_FILE_NAME = "conflicting_file.txt";
+  private static final String CONFLICTING_FILE_ORIGINAL_CONTENT =
+      "First original line\nSecond original line";
+  private static final String CONFLICTING_FILE_DIFF =
+      "diff --git a/conflicting_file.txt b/conflicting_file.txt\n"
+          + "--- a/conflicting_file.txt\n"
+          + "+++ b/conflicting_file.txt\n"
+          + "@@ -1,2 +1 @@\n"
+          + "-First original line\n"
+          + "-Third original line\n"
+          + "+Modified line\n";
+
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
@@ -353,6 +365,36 @@
   }
 
   @Test
+  public void applyPatchWithConflict_createsConflictMarkers() throws Exception {
+    initBaseWithFile(CONFLICTING_FILE_NAME, CONFLICTING_FILE_ORIGINAL_CONTENT);
+    String patch = CONFLICTING_FILE_DIFF;
+    ApplyPatchPatchSetInput in = buildInput(patch);
+    in.commitMessage = "subject";
+    in.patch.allowConflicts = true;
+
+    ChangeInfo result = applyPatch(in);
+    assertThat(
+            gApi.changes()
+                .id(result.id)
+                .current()
+                .file("conflicting_file.txt")
+                .content()
+                .asString())
+        .isEqualTo(
+            "<<<<<<< HEAD\n"
+                + "First original line\n"
+                + "Second original line\n"
+                + "=======\n"
+                + "Modified line\n"
+                + ">>>>>>> PATCH");
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .contains(
+            "ATTENTION: Conflicts occurred while applying the patch.\n"
+                + "Please resolve conflict markers.");
+    assertThat(result.containsGitConflicts).isTrue();
+  }
+
+  @Test
   public void applyPatchWithConflict_appendErrorsToCommitMessageWithLargeOriginalPatch()
       throws Exception {
     initBaseWithFile(MODIFIED_FILE_NAME, "Unexpected base content");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 12d3ced..46422e2 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -95,6 +95,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -110,7 +111,6 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -169,8 +169,11 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+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;
@@ -268,6 +271,7 @@
     assertThat(c.changeId).isEqualTo(r.getChangeId());
     assertThat(c.created).isEqualTo(c.updated);
     assertThat(c._number).isEqualTo(r.getChange().getId().get());
+    assertThat(c.currentRevisionNumber).isEqualTo(r.getPatchSetId().get());
 
     assertThat(c.owner._accountId).isEqualTo(admin.id().get());
     assertThat(c.owner.name).isNull();
@@ -513,7 +517,7 @@
             .reviewer("byemail3@example.com", CC, false)
             .reviewer("byemail4@example.com", CC, false);
     ReviewResult result = gApi.changes().id(changeId).current().review(in);
-    assertThat(result.changeInfo).isNull();
+    assertThat(result.changeInfo).isNotNull();
     assertThat(result.reviewers).isNotEmpty();
     ChangeInfo info = gApi.changes().id(changeId).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
@@ -1125,17 +1129,20 @@
     gApi.changes().id(r.getChangeId()).current().createDraft(dri);
     Change.Id num = r.getChange().getId();
 
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftComments(num, user.id())))
-          .isNotEmpty();
-    }
+    assertThat(getDraftsCountForChange(num, user.id())).isGreaterThan(0);
 
     requestScopeOperations.setApiUser(admin.id());
 
     gApi.changes().id(r.getChangeId()).delete();
+    assertThat(getDraftsCountForChange(num, user.id())).isEqualTo(0);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int getDraftsCountForChange(Change.Id changeId, Account.Id accountId) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftComments(num, user.id())))
-          .isEmpty();
+      return repo.getRefDatabase()
+          .getRefsByPrefix(RefNames.refsDraftComments(changeId, accountId))
+          .size();
     }
   }
 
@@ -1152,11 +1159,13 @@
         .modifyFile(FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
 
     requestScopeOperations.setApiUser(admin.id());
+    String expected = RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
     try (Repository repo = repoManager.openRepository(project)) {
-      String expected =
-          RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isNotEmpty();
       gApi.changes().id(changeId).delete();
+    }
+    // On google infra, repo should be reopened for getting updated refs.
+    try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isEmpty();
     }
   }
@@ -1457,7 +1466,9 @@
     assertThat(r.reviewers).hasSize(1);
     ReviewerInfo reviewer = r.reviewers.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    if (server.isUsernameSupported()) {
+      assertThat(reviewer.username).isEqualTo(username);
+    }
   }
 
   @Test
@@ -1476,7 +1487,9 @@
     assertThat(r.reviewers).hasSize(1);
     ReviewerInfo reviewer = r.reviewers.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    if (server.isUsernameSupported()) {
+      assertThat(reviewer.username).isEqualTo(username);
+    }
   }
 
   @Test
@@ -1485,20 +1498,20 @@
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
     PushOneCommit.Result result = createChange();
-    String username = "user@domain.com";
-    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
+    String email = "user@domain.com";
+    Account.Id id = accountOperations.newAccount().preferredEmail(email).inactive().create();
 
     ReviewerInput in = new ReviewerInput();
-    in.reviewer = username;
+    in.reviewer = email;
     in.state = ReviewerState.CC;
     ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
-    assertThat(r.input).isEqualTo(username);
+    assertThat(r.input).isEqualTo(email);
     assertThat(r.error).isNull();
     assertThat(r.ccs).hasSize(1);
     AccountInfo reviewer = r.ccs.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    assertThat(reviewer.email).isEqualTo(email);
   }
 
   @Test
@@ -1528,7 +1541,6 @@
   private void testAddReviewerViaPostReview(AddReviewerCaller addReviewer) throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     addReviewer.call(r.getChangeId(), user.email());
@@ -1552,16 +1564,9 @@
     // Nobody was added as CC.
     assertThat(c.reviewers.get(CC)).isNull();
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
-
-    // Change status of reviewer and ensure ETag is updated.
-    oldETag = rsrc.getETag();
-    accountOperations.account(user.id()).forUpdate().status("new status").update();
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
   }
 
   @Test
@@ -1587,17 +1592,22 @@
 
     String username1 = name("user1");
     String email1 = username1 + "@example.com";
-    accountOperations
-        .newAccount()
-        .username(username1)
-        .preferredEmail(email1)
-        .fullname("User1")
-        .create();
+    Account.Id user1Id =
+        accountOperations
+            .newAccount()
+            .username(username1)
+            .preferredEmail(email1)
+            .fullname("User1")
+            .create();
     in.reviewer = email1;
     in.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
-    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
-        .containsExactly(user.username(), username1);
+    if (server.isUsernameSupported()) {
+      assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
+          .containsExactly(user.username(), username1);
+    }
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a._accountId))
+        .containsExactly(user.id().get(), user1Id.get());
   }
 
   @Test
@@ -1639,7 +1649,6 @@
   public void addReviewerThatIsNotPerfectMatch() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
@@ -1677,9 +1686,8 @@
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfTestUser.get());
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
@@ -1688,7 +1696,6 @@
   public void addGroupAsReviewersWhenANotPerfectMatchedUserExists() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "kobe" with one user: lee
@@ -1738,9 +1745,8 @@
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfGroupUser.get());
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
@@ -1789,7 +1795,6 @@
   public void addSelfAsReviewer() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
     Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     ReviewerInput in = new ReviewerInput();
@@ -1807,9 +1812,8 @@
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
 
-    // Ensure ETag and lastUpdatedOn are updated.
+    // Ensure lastUpdatedOn is updated.
     rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
@@ -1821,7 +1825,9 @@
   @Test
   public void implicitlyCcOnNonVotingReviewForUserWithoutUserNamePgStyle() throws Exception {
     com.google.gerrit.acceptance.TestAccount accountWithoutUsername = accountCreator.create();
-    assertThat(accountWithoutUsername.username()).isNull();
+    if (server.isUsernameSupported()) {
+      assertThat(accountWithoutUsername.username()).isNull();
+    }
     testImplicitlyCcOnNonVotingReviewPgStyle(accountWithoutUsername);
   }
 
@@ -1898,56 +1904,6 @@
   }
 
   @Test
-  public void eTagChangesWhenOwnerUpdatesAccountStatus() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeResource rsrc = parseResource(r);
-    String oldETag = rsrc.getETag();
-
-    accountOperations.account(admin.id()).forUpdate().status("new status").update();
-    rsrc = parseResource(r);
-    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
-  }
-
-  @Test
-  public void pluginCanContributeToETagComputation() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String oldETag = parseResource(r).getETag();
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
-      assertThat(parseResource(r).getETag()).isNotEqualTo(oldETag);
-    }
-
-    assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
-  }
-
-  @Test
-  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String oldETag = parseResource(r).getETag();
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
-      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
-  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String oldETag = parseResource(r).getETag();
-
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                TestChangeETagComputation.withException(
-                    new StorageException("exception during test")))) {
-      assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
   public void emailNotificationForFileLevelComment() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -2302,7 +2258,7 @@
   }
 
   @Test
-  public void removeReviewerSelfFromMergedChangeNotPermitted() throws Exception {
+  public void removeReviewerSelfFromMergedChangeNotPossible() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
 
@@ -2314,11 +2270,11 @@
     gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
 
     requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
+    ResourceConflictException thrown =
         assertThrows(
-            AuthException.class,
+            ResourceConflictException.class,
             () -> gApi.changes().id(r.getChangeId()).reviewer("self").remove());
-    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
   }
 
   @Test
@@ -2503,7 +2459,25 @@
   }
 
   @Test
-  public void deleteVoteAlwaysPermittedForSelfVotes() throws Exception {
+  public void deleteVoteFromMergedChangeNotPossible() throws Exception {
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(admin.id().toString())
+                    .deleteVote(LabelId.CODE_REVIEW));
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
+  public void deleteVoteFromOpenChangeAlwaysPermittedForSelfVotes() throws Exception {
     projectOperations
         .project(project)
         .forUpdate()
@@ -2524,7 +2498,7 @@
     String changeId = r.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+    approve(changeId);
 
     gApi.changes()
         .id(r.getChangeId())
@@ -2533,7 +2507,38 @@
   }
 
   @Test
-  public void deleteVoteAlwaysPermittedForAdmin() throws Exception {
+  public void deleteVoteFromClosedChangeNotPossibleForSelfVotes() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(user.id().toString())
+                    .deleteVote(LabelId.CODE_REVIEW));
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
+  public void deleteVoteFromOpenChangeAlwaysPermittedForAdmin() throws Exception {
     projectOperations
         .project(project)
         .forUpdate()
@@ -2554,7 +2559,7 @@
     String changeId = r.getChangeId();
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+    approve(changeId);
 
     requestScopeOperations.setApiUser(admin.id());
     gApi.changes()
@@ -2564,6 +2569,38 @@
   }
 
   @Test
+  public void deleteVoteFromMergedChangeNotPossibleForAdmin() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).current().submit();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .reviewer(user.id().toString())
+                    .deleteVote(LabelId.CODE_REVIEW));
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
         label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -3470,7 +3507,9 @@
     // 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());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+    }
 
     // Add another +1 vote as user
     requestScopeOperations.setApiUser(user.id());
@@ -3486,8 +3525,12 @@
         .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());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
+          .containsExactly(admin.username(), user.username());
+    }
+    assertThat(codeReviewApprovals.stream().map(a -> a._accountId).collect(toList()))
+        .containsExactly(admin.id().get(), user.id().get());
   }
 
   @Test
@@ -3602,12 +3645,16 @@
 
     assertThat(codeReviewApprovals).hasSize(1);
     assertThat(codeReviewApprovals.get(0).value).isEqualTo(2);
-    assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    if (server.isUsernameSupported()) {
+      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());
+    if (server.isUsernameSupported()) {
+      assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    }
     assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
   }
 
@@ -4007,6 +4054,24 @@
   }
 
   @Test
+  public void getCommitMessageThatHasDuplicateFooters() throws Exception {
+    String subject = "Change Subject";
+    String changeId = "I" + ObjectId.toString(CommitMessageUtil.generateChangeId());
+    String commitMessage =
+        String.format(
+            "%s\n\nFirst Paragraph.\n\nSecond Paragraph\n\nFoo: Bar\nFoo: Baz\nChange-Id: %s\n",
+            subject, changeId);
+    changeOperations.newChange().project(project).commitMessage(commitMessage).create();
+
+    CommitMessageInfo commitMessageInfo = gApi.changes().id(changeId).getMessage();
+    assertThat(commitMessageInfo.subject).isEqualTo(subject);
+    assertThat(commitMessageInfo.fullMessage).isEqualTo(commitMessage);
+
+    // only the last "Foo" footer is returned
+    assertThat(commitMessageInfo.footers).containsExactly("Foo", "Baz", "Change-Id", changeId);
+  }
+
+  @Test
   public void changeCommitMessage() throws Exception {
     // Tests mutating the commit message as both the owner of the change and a regular user with
     // addPatchSet permission. Asserts that both cases succeed.
@@ -4154,6 +4219,8 @@
     PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
+    assertThat(gApi.changes().id(r.getChangeId()).info().owner._accountId)
+        .isEqualTo(user.id().get());
     // Try to change the commit message
     AuthException thrown =
         assertThrows(
@@ -4174,6 +4241,24 @@
   }
 
   @Test
+  public void changeCommitMessageInvokesCommitValidators() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+    requestScopeOperations.setApiUser(admin.id());
+    String newMessage = "modified commit message\nChange-Id: " + r.getChangeId() + "\n";
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+      assertThat(testCommitValidationListener.receiveEvent).isNotNull();
+    }
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -4565,6 +4650,11 @@
     gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
     int number = gApi.changes().id(change.getChangeId()).get()._number;
+
+    // Note: Computing the description of some UI actions does access the index. If the index is
+    // disabled computing these descriptions fails. UiActions#describe catches and ignores these
+    // exceptions so that the request is still successful. In this case the description of the UI
+    // action is omitted in the response.
     try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
       assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
           .isEqualTo(change.getChangeId());
@@ -4816,4 +4906,15 @@
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
+
+  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/api/change/DefaultSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
new file mode 100644
index 0000000..9af7a2d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
@@ -0,0 +1,80 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.util.Comparator;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class DefaultSubmitRequirementsIT extends AbstractDaemonTest {
+  /**
+   * Tests the "No-Unresolved-Comments" submit requirement that is created during the site
+   * initialization.
+   */
+  @Test
+  public void cannotSubmitChangeWithUnresolvedComment() throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(project);
+    PushOneCommit.Result r =
+        createChange(repo, "master", "Add a file", "foo", "content", /* topic= */ null);
+    String changeId = r.getChangeId();
+    CommentInfo commentInfo =
+        addComment(changeId, "foo", "message", /* unresolved= */ true, /* inReplyTo= */ null);
+    assertThat(commentInfo.unresolved).isTrue();
+    approve(changeId);
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %1$s: Change %1$s is not ready: "
+                    + "submit requirement 'No-Unresolved-Comments' is unsatisfied.",
+                r.getChange().getId().get()));
+
+    // Resolve the comment and check that the change can be submitted now.
+    CommentInfo commentInfo2 =
+        addComment(
+            changeId, "foo", "reply", /* unresolved= */ false, /* inReplyTo= */ commentInfo.id);
+    assertThat(commentInfo2.unresolved).isFalse();
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  @CanIgnoreReturnValue
+  private CommentInfo addComment(
+      String changeId, String file, String message, boolean unresolved, @Nullable String inReplyTo)
+      throws Exception {
+    ReviewInput in = new ReviewInput();
+    CommentInput commentInput = new CommentInput();
+    commentInput.path = file;
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.unresolved = unresolved;
+    commentInput.inReplyTo = inReplyTo;
+    in.comments = ImmutableMap.of(file, ImmutableList.of(commentInput));
+    gApi.changes().id(changeId).current().review(in);
+
+    return gApi.changes().id(changeId).commentsRequest().getAsList().stream()
+        .filter(commentInfo -> commentInput.message.equals(commentInfo.message))
+        // if there are multiple comments with the same message, take the one was created last
+        .max(
+            Comparator.comparing(commentInfo1 -> commentInfo1.updated.toInstant().getEpochSecond()))
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format("comment '%s' not found", commentInput.message)));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 3771bb9..56910e2 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -46,7 +46,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 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;
@@ -64,6 +63,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -93,6 +93,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -153,6 +154,10 @@
       @Override
       public void configure() {
         CommentValidator mockCommentValidator = mock(CommentValidator.class);
+
+        // by default return no validation errors
+        when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+
         bind(CommentValidator.class)
             .annotatedWith(Exports.named(mockCommentValidator.getClass()))
             .toInstance(mockCommentValidator);
@@ -612,7 +617,7 @@
       LabelType.Builder verified =
           labelBuilder(
                   LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"))
-              .setFunction(LabelFunction.NO_BLOCK);
+              .setNoBlockFunction();
       u.getConfig().upsertLabelType(verified.build());
       u.save();
     }
@@ -758,6 +763,26 @@
   }
 
   @Test
+  public void currentRevisionNumberIsSetOnReturnedChangeInfo() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeInfo =
+        gApi.changes().id(r.getChangeId()).current().review(ReviewInput.dislike()).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(1);
+    amendChange(r.getChangeId());
+    changeInfo =
+        gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend()).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(2);
+
+    // Check that the current revision number is also returned when list changes options are
+    // requested.
+    ReviewInput reviewInput = ReviewInput.approve();
+    reviewInput.responseFormatOptions =
+        ImmutableList.copyOf(EnumSet.allOf(ListChangesOption.class));
+    changeInfo = gApi.changes().id(r.getChangeId()).current().review(reviewInput).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(2);
+  }
+
+  @Test
   public void submitRulesAreInvokedOnlyOnce() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -771,6 +796,20 @@
   }
 
   @Test
+  public void submitRulesAreInvokedOnlyOnce_allOptionsSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      input.responseFormatOptions = ImmutableList.copyOf(EnumSet.allOf(ListChangesOption.class));
+      gApi.changes().id(r.getChangeId()).current().review(input);
+    }
+
+    assertThat(testSubmitRule.count).isEqualTo(1);
+  }
+
+  @Test
   public void addingReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
new file mode 100644
index 0000000..a6c3c27
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.DefaultPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class QueryChangesFilterPermissionBackendIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Singleton
+  public static class TestPermissionBackend extends PermissionBackend {
+    private final DefaultPermissionBackend defaultPermissionBackend;
+    private final AtomicReference<String> extraQueryFilter;
+
+    public static class Module extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(PermissionBackend.class).to(TestPermissionBackend.class).in(Scopes.SINGLETON);
+      }
+    }
+
+    @Inject
+    TestPermissionBackend(DefaultPermissionBackend defaultPermissionBackend) {
+      this.defaultPermissionBackend = defaultPermissionBackend;
+      this.extraQueryFilter = new AtomicReference<>();
+    }
+
+    @Override
+    public WithUser currentUser() {
+      return new TestPermissionWithUser(defaultPermissionBackend.currentUser());
+    }
+
+    @Override
+    public WithUser user(CurrentUser user) {
+      return new TestPermissionWithUser(defaultPermissionBackend.user(user));
+    }
+
+    @Override
+    public WithUser absentUser(Account.Id id) {
+      return new TestPermissionWithUser(defaultPermissionBackend.absentUser(id));
+    }
+
+    public String getExtraQueryFilter() {
+      return extraQueryFilter.get();
+    }
+
+    public void setExtraQueryFilter(String extraQueryFilter) {
+      this.extraQueryFilter.set(extraQueryFilter);
+    }
+
+    class TestPermissionWithUser extends WithUser {
+
+      private final WithUser defaultPermissioBackendWithUser;
+
+      TestPermissionWithUser(WithUser defaultPermissioBackendWithUser) {
+        this.defaultPermissioBackendWithUser = defaultPermissioBackendWithUser;
+      }
+
+      @Override
+      public ForProject project(Project.NameKey project) {
+        return defaultPermissioBackendWithUser.project(project);
+      }
+
+      @Override
+      public void check(GlobalOrPluginPermission perm)
+          throws AuthException, PermissionBackendException {
+        defaultPermissioBackendWithUser.check(perm);
+      }
+
+      @Override
+      public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+          throws PermissionBackendException {
+        return defaultPermissioBackendWithUser.test(permSet);
+      }
+
+      @Override
+      public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+        return defaultPermissioBackendWithUser.testCond(perm);
+      }
+
+      @Override
+      public String filterQueryChanges() {
+        return extraQueryFilter.get();
+      }
+    }
+  }
+
+  @Override
+  public Module createModule() {
+    return new TestPermissionBackend.Module();
+  }
+
+  @Test
+  public void filterHidenProjectByAuthenticationBackend() throws Exception {
+    String projectChangeId = createChange().getChangeId();
+
+    Project.NameKey hiddenProject = projectOperations.newProject().create();
+    TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
+    createChange(hiddenRepo);
+
+    String changeQuery = "author:self OR status:open";
+    assertThat(gApi.changes().query(changeQuery).get()).hasSize(2);
+
+    server
+        .getTestInjector()
+        .getInstance(TestPermissionBackend.class)
+        .setExtraQueryFilter("-project:" + hiddenProject);
+    List<ChangeInfo> projectChanges = gApi.changes().query(changeQuery).get();
+    assertThat(projectChanges.stream().map(c -> c.changeId)).containsExactly(projectChangeId);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 7f21eb6..a5de579 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -858,6 +858,24 @@
     }
 
     @Test
+    public void rebaseChangeWithRefsHeadsMaster() throws Exception {
+      RevCommit desiredBase =
+          createNewCommitWithoutChangeId(/*branch=*/ "refs/heads/master", "file", "content");
+      PushOneCommit.Result child = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // rebase child onto desiredBase (referenced by ref)
+      ri.base = "refs/heads/master";
+      rebaseCallWithInput.call(child.getChangeId(), ri);
+
+      PatchSet ps2 = child.getPatchSet();
+      assertThat(ps2.id().get()).isEqualTo(2);
+      RevisionInfo childInfo =
+          get(child.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(childInfo.commit.parents.get(0).commit).isEqualTo(desiredBase.name());
+    }
+
+    @Test
     public void cannotRebaseChangeWithInvalidBaseCommit() throws Exception {
       // Create another branch and push the desired parent commit to it.
       String branchName = "foo";
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index cd3e76d..10dd5e3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.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.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -413,6 +414,12 @@
   @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   public void revertWithNonVisibleUsers() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     // Define readable names for the users we use in this test.
     TestAccount reverter = user;
     TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 5f44f99..6dea5be 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -52,7 +52,6 @@
 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;
@@ -1235,7 +1234,7 @@
 
   @Test
   public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
-    updateVerifiedLabel(b -> b.setFunction(LabelFunction.NO_BLOCK));
+    updateVerifiedLabel(b -> b.setNoBlockFunction());
 
     // 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
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index c7d1c5e..44b0179 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -92,6 +92,7 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
@@ -103,6 +104,11 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
 
+  @Before
+  public void setup() throws RestApiException {
+    removeDefaultSubmitRequirements();
+  }
+
   @Test
   public void submitRecords() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -631,6 +637,252 @@
   }
 
   @Test
+  public void submitRequirement_wantCodeReviewFromHumanReviewers() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Want-Code-Review-From-All")
+            .setApplicabilityExpression(
+                Optional.of(
+                    SubmitRequirementExpression.create(
+                        "-label:Code-Review>=1,users=human_reviewers")))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review>=1,users=human_reviewers"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Code-Review is unsatisfied because there is no Code-Review+2 approval.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is unsatisfied (since a review from reviewers is required but no
+    // reviewer is present on the change).
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Add some reviewers
+    TestAccount reviewer1 = accountCreator.create("reviewer1");
+    gApi.changes().id(changeId).addReviewer("reviewer1");
+    TestAccount reviewer2 = accountCreator.create("reviewer2");
+    gApi.changes().id(changeId).addReviewer("reviewer2");
+
+    // Code-Review is unsatisfied because there is no Code-Review+2 approval.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is unsatisfied since there are reviewers on the change that
+    // didn't approve it yet.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    requestScopeOperations.setApiUser(reviewer1.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Code-Review is satisfied because there is Code-Review+2 approval from reviewer1.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is unsatisfied since there is no approval from reviewer2.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    requestScopeOperations.setApiUser(reviewer2.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Code-Review is satisfied because there are Code-Review+2 approvals from reviewer1 and
+    // reviewer2.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is not applicable since there are approval from all reviewers
+    // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX,users=human_reviewers"
+    // satisfied.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+
+    // Add another reviewer
+    TestAccount reviewer3 = accountCreator.create("reviewer3");
+    gApi.changes().id(changeId).addReviewer("reviewer3");
+
+    // Want-Code-Review-From-All is unsatisfied because reviewer3 didn't vote yet.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Vote with Code-Review+1 by reviewer3.
+    requestScopeOperations.setApiUser(reviewer3.id());
+    voteLabel(changeId, "Code-Review", 1);
+
+    // Want-Code-Review-From-All is not applicable because all reviewers voted with Code-Review >=
+    // 1.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_wantCodeReviewFromHumanReviewers_enabledByFooter()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Want-Code-Review-From-All")
+            .setApplicabilityExpression(
+                Optional.of(
+                    SubmitRequirementExpression.create(
+                        "footer:\"Want-Code-Review: all\" -label:Code-Review>=1,users=human_reviewers")))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review>=1,users=human_reviewers"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Add some reviewers
+    TestAccount reviewer1 = accountCreator.create("reviewer1");
+    gApi.changes().id(changeId).addReviewer("reviewer1");
+    TestAccount reviewer2 = accountCreator.create("reviewer2");
+    gApi.changes().id(changeId).addReviewer("reviewer2");
+
+    // Approve by one reviewer
+    requestScopeOperations.setApiUser(reviewer1.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Code-Review is satisfied because there is Code-Review+2 approval from reviewer1.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+
+    // Want-Code-Review-From-All is not applicable since the commit message doesn't contain a
+    // "Want-Code-Review: all" footer.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+
+    // Amend the change to add a "Want-Code-Review: all" footer.
+    amendChange(
+        changeId,
+        PushOneCommit.SUBJECT
+            + "\n\nSome Description\n\nChange-Id: "
+            + changeId
+            + "\nWant-Code-Review: all\n",
+        PushOneCommit.FILE_NAME,
+        "content");
+
+    // Re-Approve by reviewer1.
+    requestScopeOperations.setApiUser(reviewer1.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Want-Code-Review-From-All is applicable since there is a "Want-Code-Review: all" footer and
+    // it is unsatisfied since there is no approval from reviewer2.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Approve by reviewer2.
+    requestScopeOperations.setApiUser(reviewer2.id());
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Want-Code-Review-From-All is not applicable since there are approval from all reviewers
+    // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX,users=human_reviewers"
+    // satisfied.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+
+    // Add another reviewer
+    TestAccount reviewer3 = accountCreator.create("reviewer3");
+    gApi.changes().id(changeId).addReviewer("reviewer3");
+
+    // Want-Code-Review-From-All is unsatisfied because reviewer3 didn't vote yet.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false);
+
+    // Vote with Code-Review+1 by reviewer3.
+    requestScopeOperations.setApiUser(reviewer3.id());
+    voteLabel(changeId, "Code-Review", 1);
+
+    // Want-Code-Review-From-All is not applicable because all reviewers voted with Code-Review >=
+    // 1.
+    assertSubmitRequirementStatus(
+        gApi.changes().id(changeId).get().submitRequirements,
+        "Want-Code-Review-From-All",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+  }
+
+  @Test
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
       throws Exception {
     configSubmitRequirement(
@@ -1597,8 +1849,7 @@
 
       // 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();
+      LabelType cr = TestLabels.codeReview().toBuilder().setNoBlockFunction().build();
       try (ProjectConfigUpdate u = updateProject(project)) {
         u.getConfig().upsertLabelType(cr);
         u.save();
@@ -1643,8 +1894,7 @@
 
       // 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();
+      LabelType cr = TestLabels.codeReview().toBuilder().setNoBlockFunction().build();
       try (ProjectConfigUpdate u = updateProject(project)) {
         u.getConfig().upsertLabelType(cr);
         u.save();
@@ -2857,7 +3107,7 @@
             .setAllowOverrideInChildProjects(true)
             .build());
 
-    LabelType cr = TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+    LabelType cr = TestLabels.codeReview().toBuilder().setNoBlockFunction().build();
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().upsertLabelType(cr);
       u.save();
@@ -3157,4 +3407,8 @@
     r.assertOkStatus();
     return r;
   }
+
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index 8643489..61a06a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -39,13 +40,19 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -467,6 +474,373 @@
     assertMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
   }
 
+  @Test
+  public void label_requireVoteFromHumanReviewers() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
+
+    Account.Id owner = accountCreator.create("owner").id();
+    Account.Id reviewer1 = accountCreator.create("reviewer1").id();
+    Account.Id reviewer2 = accountCreator.create("reviewer2").id();
+    Account.Id reviewer3 = accountCreator.create("reviewer3").id();
+
+    Account.Id serviceUser = accountCreator.create("serviceUser").id();
+    gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).addMembers(serviceUser.toString());
+
+    Change.Id changeApprovedByAllReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeApprovedByAllReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(
+        project,
+        changeApprovedByAllReviewers,
+        ReviewInput.approve(),
+        reviewer1,
+        reviewer2,
+        reviewer3);
+
+    Change.Id changeApprovedBySomeReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeApprovedBySomeReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(project, changeApprovedBySomeReviewers, ReviewInput.approve(), reviewer1, reviewer2);
+
+    Change.Id changeRecommendedByAllReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeRecommendedByAllReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(
+        project,
+        changeRecommendedByAllReviewers,
+        ReviewInput.recommend(),
+        reviewer1,
+        reviewer2,
+        reviewer3);
+
+    Change.Id changeRecommendedBySomeReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeRecommendedBySomeReviewers, reviewer1, reviewer2, reviewer3);
+    addReviews(
+        project, changeRecommendedBySomeReviewers, ReviewInput.recommend(), reviewer1, reviewer2);
+
+    Change.Id changeNoVotesByReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(project, changeNoVotesByReviewers, reviewer1, reviewer2, reviewer3);
+
+    Change.Id changeWithoutReviewers =
+        changeOperations.newChange().project(project).owner(owner).create();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // change without reviewers doesn't match
+    assertNotMatching("label:Code-Review=MAX,users=human_reviewers", changeWithoutReviewers);
+
+    // match changes where all reviewers have the same vote
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review=2,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review=1,users=human_reviewers",
+        ImmutableList.of(changeRecommendedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeApprovedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // match changes where no reviewer voted (same as "label:Code-Review=0")
+    assertRequirement(
+        "label:Code-Review=0,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewers),
+        ImmutableList.of(
+            changeApprovedByAllReviewers,
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers));
+
+    // match changes where all reviewers have a vote <=, >=, < or >
+    assertRequirement(
+        "label:Code-Review<=2,users=human_reviewers",
+        ImmutableList.of(
+            changeApprovedByAllReviewers,
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review<=1,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers),
+        ImmutableList.of(changeApprovedByAllReviewers));
+    assertRequirement(
+        "label:Code-Review>=1,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers, changeRecommendedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review<1,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewers),
+        ImmutableList.of(
+            changeApprovedByAllReviewers,
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers));
+    assertRequirement(
+        "label:Code-Review>1,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // match changes where all reviewers have any (non-zero) vote
+    assertRequirement(
+        "label:Code-Review=ANY,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers, changeRecommendedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // votes of the change owners are ignored (as the change owner is not considered as a reviewer)
+    addReviews(project, changeApprovedByAllReviewers, ReviewInput.dislike(), owner);
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // missing votes from service users are fine
+    addReviewers(project, changeApprovedByAllReviewers, serviceUser);
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // votes from service users are ignored
+    addReviews(project, changeApprovedByAllReviewers, ReviewInput.dislike(), serviceUser);
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(changeApprovedByAllReviewers),
+        ImmutableList.of(
+            changeApprovedBySomeReviewers,
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+
+    // when reviewers by email are present changes do not match, unless the expected value is 0
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig cfg = projectConfigFactory.create(project);
+      cfg.load(md);
+      cfg.updateProject(
+          update ->
+              update.setBooleanConfig(
+                  BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      cfg.commit(md);
+    }
+    projectCache.evictAndReindex(project);
+    Change.Id changeRecommendedByAllReviewersWithReviewersByEmail =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(
+        project,
+        changeRecommendedByAllReviewersWithReviewersByEmail,
+        reviewer1,
+        reviewer2,
+        reviewer3);
+    addReviews(
+        project,
+        changeRecommendedByAllReviewersWithReviewersByEmail,
+        ReviewInput.recommend(),
+        reviewer1,
+        reviewer2,
+        reviewer3);
+    addReviewer(
+        project,
+        changeRecommendedByAllReviewersWithReviewersByEmail,
+        "email-without-account@example.com");
+    Change.Id changeNoVotesByReviewersWithReviewersByEmail =
+        changeOperations.newChange().project(project).owner(owner).create();
+    addReviewers(
+        project, changeNoVotesByReviewersWithReviewersByEmail, reviewer1, reviewer2, reviewer3);
+    addReviewer(
+        project, changeNoVotesByReviewersWithReviewersByEmail, "email-without-account@example.com");
+    assertRequirement(
+        "label:Code-Review=MAX,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review=2,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review=ANY,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review=0,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of(changeRecommendedByAllReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<=2,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review<=0,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of(changeRecommendedByAllReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<=-1,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<2,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review<1,users=human_reviewers",
+        ImmutableList.of(changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of(changeRecommendedByAllReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review<0,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review>=0,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review>=1,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+    assertRequirement(
+        "label:Code-Review>-1,users=human_reviewers",
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail),
+        ImmutableList.of());
+    assertRequirement(
+        "label:Code-Review>0,users=human_reviewers",
+        ImmutableList.of(),
+        ImmutableList.of(
+            changeRecommendedByAllReviewersWithReviewersByEmail,
+            changeNoVotesByReviewersWithReviewersByEmail));
+
+    // cannot combine users=human_reviewers" with submit record status
+    assertError(
+        "label:Code-Review=ok,users=human_reviewers",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users=human_reviewers' argument in conjunction with a submit record label"
+            + " status");
+
+    // cannot combine "users" arg with a "user" arg
+    assertError(
+        "label:Code-Review=MAX,users=human_reviewers,user=reviewer1",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+
+    // cannot combine "users" arg with a "group" arg
+    assertError(
+        "label:Code-Review=MAX,users=human_reviewers,group=foo",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+
+    // cannot combine "users" arg with a positional arg
+    assertError(
+        "label:Code-Review=MAX,users=human_reviewers,reviewer1",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+    assertError(
+        "label:Code-Review=MAX,reviewer1,users=human_reviewers",
+        changeApprovedByAllReviewers,
+        "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user',"
+            + " group')");
+
+    // label without "users=human_reviewers" still works
+    assertRequirement(
+        "label:Code-Review=MAX,user=reviewer1",
+        ImmutableList.of(changeApprovedByAllReviewers, changeApprovedBySomeReviewers),
+        ImmutableList.of(
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+    assertRequirement(
+        "label:Code-Review=MAX,reviewer1",
+        ImmutableList.of(changeApprovedByAllReviewers, changeApprovedBySomeReviewers),
+        ImmutableList.of(
+            changeRecommendedByAllReviewers,
+            changeRecommendedBySomeReviewers,
+            changeNoVotesByReviewers));
+  }
+
+  private void addReviewers(Project.NameKey project, Change.Id changeId, Account.Id... reviewers)
+      throws Exception {
+    for (Account.Id reviewer : reviewers) {
+      addReviewer(project, changeId, reviewer.toString());
+    }
+  }
+
+  private void addReviewer(Project.NameKey project, Change.Id changeId, String reviewer)
+      throws Exception {
+    gApi.changes().id(project.get(), changeId.get()).addReviewer(reviewer);
+  }
+
+  private void addReviews(
+      Project.NameKey project, Change.Id changeId, ReviewInput reviewInput, Account.Id... reviewers)
+      throws Exception {
+    for (Account.Id reviewer : reviewers) {
+      requestScopeOperations.setApiUser(reviewer);
+      gApi.changes().id(project.get(), changeId.get()).current().review(reviewInput);
+    }
+  }
+
   private void approveAsUser(String changeId, Account.Id userId) throws Exception {
     requestScopeOperations.setApiUser(userId);
     approve(changeId);
@@ -540,13 +914,28 @@
     return threeWayMerger.getResultTreeId();
   }
 
+  private void assertRequirement(
+      String requirement,
+      ImmutableList<Change.Id> matchingChanges,
+      ImmutableList<Change.Id> nonMatchingChanges) {
+    for (Change.Id matchingChange : matchingChanges) {
+      assertMatching(requirement, matchingChange);
+    }
+
+    for (Change.Id nonMatchingChange : nonMatchingChanges) {
+      assertNotMatching(requirement, nonMatchingChange);
+    }
+  }
+
   private void assertMatching(String requirement, Change.Id change) {
-    assertThat(evaluate(requirement, change).status())
+    assertWithMessage("requirement \"%s\" doesn't match change %s", requirement, change)
+        .that(evaluate(requirement, change).status())
         .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
   }
 
   private void assertNotMatching(String requirement, Change.Id change) {
-    assertThat(evaluate(requirement, change).status())
+    assertWithMessage("requirement \"%s\" matches change %s", requirement, change)
+        .that(evaluate(requirement, change).status())
         .isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
index af95e7e..e7efb62 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -98,6 +98,7 @@
     assertThat(recordLabels).contains(needCustomLabel);
   }
 
+  @SuppressWarnings("deprecation")
   private void setupCustomBlockingLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index d1d3620..83de9824 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -28,8 +28,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -37,14 +36,12 @@
 import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.testing.ConfigSuite;
 import java.io.IOException;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -60,7 +57,7 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  private class RulesPl extends VersionedMetaData {
+  private static class RulesPl extends VersionedMetaData {
     private String rule;
 
     @Override
@@ -69,21 +66,12 @@
     }
 
     @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
+    protected void onLoad() throws IOException {
       rule = readUTF8(RULES_PL_FILE);
     }
 
     @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-      TestSubmitRuleInput in = new TestSubmitRuleInput();
-      in.rule = rule;
-      try {
-        @SuppressWarnings("unused")
-        var unused = gApi.changes().id(testChangeId.get()).current().testSubmitType(in);
-      } catch (RestApiException e) {
-        throw new ConfigInvalidException("Invalid submit type rule", e);
-      }
-
+    protected boolean onSave(CommitBuilder commit) throws IOException {
       saveUTF8(RULES_PL_FILE, rule);
       return true;
     }
@@ -91,18 +79,10 @@
 
   private AtomicInteger fileCounter;
 
-  // The change is used only to verify that the rule is valid. It is never submitted in the test.
-  private Change.Id testChangeId;
-
   @Before
   public void setUp() throws Exception {
     fileCounter = new AtomicInteger();
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
-    Result testChange = createChange("test", "test change");
-    testChangeId = testChange.getChange().getId();
-    // Reset repo back to the original state - otherwise all changes in tests have testChange as a
-    // parent.
-    testRepo.reset(testChange.getCommit().getParent(0));
   }
 
   private void setRulesPl(String rule) throws Exception {
@@ -193,6 +173,21 @@
   }
 
   @Test
+  @GerritConfig(name = "rules.enable", value = "false")
+  public void submitType_rulesTakeNoEffectWhenDisabled() throws Exception {
+    PushOneCommit.Result r1 = createChange("master", "Default 1");
+    PushOneCommit.Result r2 = createChange("master", "FAST_FORWARD_ONLY 2");
+    PushOneCommit.Result r3 = createChange("master", "MERGE_IF_NECESSARY 3");
+
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    // Rules take no effect
+    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r2.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
+  }
+
+  @Test
   public void submitTypeIsUsedForSubmit() throws Exception {
     setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index b0d39d5..625a00e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
-import static com.google.gerrit.entities.LabelFunction.NO_BLOCK;
 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.labelBuilder;
@@ -603,7 +602,7 @@
                       value(0, "No score"),
                       value(-1, "Negative"))
                   .toBuilder()
-                  .setFunction(NO_BLOCK)
+                  .setNoBlockFunction()
                   .build());
       u.save();
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/config/GetExperimentIT.java b/javatests/com/google/gerrit/acceptance/api/config/GetExperimentIT.java
new file mode 100644
index 0000000..0609b5c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/GetExperimentIT.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class GetExperimentIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotGetAsNonAdmin() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException exception =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.config()
+                    .server()
+                    .experiment(ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS)
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("administrate server not permitted");
+  }
+
+  @Test
+  public void cannotGetNonExistingExperiment() throws Exception {
+    ResourceNotFoundException exception =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.config().server().experiment("non-existing").get());
+    assertThat(exception).hasMessageThat().isEqualTo("non-existing");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"GerritBackendFeature__attach_nonce_to_documentation"})
+  public void getEnabled() throws Exception {
+    ExperimentInfo experimentInfo =
+        gApi.config()
+            .server()
+            .experiment(
+                ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+            .get();
+    assertThat(experimentInfo.enabled).isTrue();
+  }
+
+  @Test
+  public void getDisabled() throws Exception {
+    ExperimentInfo experimentInfo =
+        gApi.config()
+            .server()
+            .experiment(
+                ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+            .get();
+    assertThat(experimentInfo.enabled).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
new file mode 100644
index 0000000..fe3cb00
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class ListExperimentsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotListAsNonAdmin() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException exception =
+        assertThrows(AuthException.class, () -> gApi.config().server().listExperiments().get());
+    assertThat(exception).hasMessageThat().isEqualTo("administrate server not permitted");
+  }
+
+  @Test
+  public void listAll() throws Exception {
+    ImmutableMap<String, ExperimentInfo> experiments =
+        gApi.config().server().listExperiments().get();
+    assertThat(experiments.keySet())
+        .containsExactly(
+            ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS,
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+        .inOrder();
+
+    // "GerritBackendFeature__check_implicit_merges_on_merge",
+    // "GerritBackendFeature__reject_implicit_merges_on_merge" and
+    // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
+    // AbstractDaemonTest#beforeTest
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE)
+                .enabled)
+        .isTrue();
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE)
+                .enabled)
+        .isTrue();
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+                .enabled)
+        .isTrue();
+
+    assertThat(
+            experiments.get(ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS).enabled)
+        .isFalse();
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+                .enabled)
+        .isFalse();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"GerritBackendFeature__attach_nonce_to_documentation"})
+  // "GerritBackendFeature__check_implicit_merges_on_merge",
+  // "GerritBackendFeature__reject_implicit_merges_on_merge" and
+  // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
+  // AbstractDaemonTest#beforeTest
+  public void listEnabled_noneEnabled() throws Exception {
+    ImmutableMap<String, ExperimentInfo> experiments =
+        gApi.config().server().listExperiments().enabledOnly().get();
+    assertThat(experiments.keySet())
+        .containsExactly(
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+        .inOrder();
+    for (ExperimentInfo experimentInfo : experiments.values()) {
+      assertThat(experimentInfo.enabled).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/CorsForPluginsIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/CorsForPluginsIT.java
new file mode 100644
index 0000000..5e9e81a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/CorsForPluginsIT.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.plugin;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.UrlEncoded;
+import com.google.gerrit.server.plugins.PluginContentScanner;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.ServletModule;
+import java.io.IOException;
+import java.io.PrintWriter;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.client.fluent.Request;
+import org.junit.Test;
+
+public class CorsForPluginsIT extends AbstractDaemonTest {
+
+  static class FooPluginHttpModule extends ServletModule {
+    @Override
+    public void configureServlets() {
+      serve("/bar").with(BarServlet.class);
+    }
+  }
+
+  @Singleton
+  static class BarServlet extends HttpServlet {
+    private static final long serialVersionUID = 1L;
+
+    @Override
+    protected void service(HttpServletRequest req, HttpServletResponse resp)
+        throws ServletException, IOException {
+      resp.setContentType("text/plain");
+      try (PrintWriter out = resp.getWriter()) {
+        out.println("Hi!");
+      }
+    }
+  }
+
+  @Test
+  public void noCorsConfig_CorsNotAllowed() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("foo", null, FooPluginHttpModule.class, null, PluginContentScanner.EMPTY)) {
+
+      RestResponse rsp = execute("/plugins/foo/Documentation/foo.html", "evil");
+      assertThat(rsp.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
+
+      rsp = execute("/plugins/foo/bar", "evil");
+      assertThat(rsp.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "site.allowOriginRegex", value = "friend")
+  public void configConfigured_onlyMatchingOriginAllowed() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("foo", null, FooPluginHttpModule.class, null, PluginContentScanner.EMPTY)) {
+
+      RestResponse rsp;
+
+      rsp = execute("/plugins/foo/Documentation/foo.html", "evil");
+      assertThat(rsp.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
+      rsp = execute("/plugins/foo/bar", "evil");
+      assertThat(rsp.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
+
+      rsp = execute("/plugins/foo/static/resource", "friend");
+      assertThat(rsp.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNotNull();
+
+      // TODO: this should also work
+      // rsp = execute("/plugins/foo/bar", "friend");
+      // assertThat(rsp.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNotNull();
+    }
+  }
+
+  private RestResponse execute(String path, String origin) throws Exception {
+    UrlEncoded url = new UrlEncoded(canonicalWebUrl.get() + path);
+    Request req = Request.Get(url.toString());
+    req.setHeader(ORIGIN, origin);
+    return adminRestSession.execute(req);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index 035b567..72b3b39 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -57,6 +58,7 @@
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 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.ResourceNotFoundException;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -1179,6 +1181,33 @@
         new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false));
   }
 
+  @Test
+  public void canUpdateConfigWithoutCreatingChangeNullByDefault() throws Exception {
+    assertThat(pApi().access().requireChangeForConfigUpdate).isNull();
+    assertThat(gApi.projects().name(allProjects.get()).access().requireChangeForConfigUpdate)
+        .isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void canUpdateConfigWithoutCreatingChangeUsesConfigValue() throws Exception {
+    assertThat(pApi().access().requireChangeForConfigUpdate).isTrue();
+    assertThat(gApi.projects().name(allProjects.get()).access().requireChangeForConfigUpdate)
+        .isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_postAccessRejected() {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    MethodNotAllowedException e =
+        assertThrows(MethodNotAllowedException.class, () -> pApi().access(accessInput));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
   private ProjectApi pApi() throws Exception {
     return gApi.projects().name(newProjectName.get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index bf3c80f..c038d2c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -376,7 +376,9 @@
     Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
 
     assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
+    String expectedMessage =
+        String.format("Patch Set 2: Cherry Picked from commit %s.", commitToCherryPick);
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
     // Cherry-pick of is not set, because the source change was not provided.
     assertThat(cherryPickResult.cherryPickOfChange).isNull();
     assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
@@ -426,8 +428,11 @@
     Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
 
     assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
-    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 3.");
+    assertThat(messageIterator.next().message)
+        .isEqualTo("Patch Set 2: Cherry Picked from branch master.");
+    String expectedMessage =
+        String.format("Patch Set 3: Cherry Picked from commit %s.", commitToCherryPick.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
     // Cherry-pick was reset to empty value.
     assertThat(cherryPickResult._number).isEqualTo(existingDestChange._number);
     assertThat(cherryPickResult.cherryPickOfChange).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
new file mode 100644
index 0000000..694cfc9
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ConfigReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private Project.NameKey defaultMessageProject;
+  private Project.NameKey customMessageProject;
+
+  @Before
+  public void setUp() throws Exception {
+    defaultMessageProject = projectOperations.newProject().create();
+    customMessageProject = projectOperations.newProject().create();
+  }
+
+  @Test
+  public void createConfigChangeWithDefaultMessage() throws Exception {
+    ConfigInput in = new ConfigInput();
+    in.description = "Test project description";
+
+    ChangeInfo changeInfo = gApi.projects().name(defaultMessageProject.get()).configReview(in);
+
+    assertThat(changeInfo.subject).isEqualTo("Review config change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("project", null, "description"))
+        .isEqualTo("Test project description");
+  }
+
+  @Test
+  public void createConfigChangeWithCustomMessage() throws Exception {
+    ConfigInput in = new ConfigInput();
+    in.description = "Test project description";
+    String customMessage = "test custom message";
+    in.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(customMessageProject.get()).configReview(in);
+
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("project", null, "description"))
+        .isEqualTo("Test project description");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index b9cbbcd..8f96f6a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -23,13 +23,16 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -102,19 +105,39 @@
     DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().dashboard(info.id).setDefault();
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG,
+        "Administrator",
+        String.format("Changed default dashboard to %s.", info.id));
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
     assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
   }
 
   @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setDefaultDashboardReject() throws Exception {
+    DashboardInfo info = createTestDashboard();
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class, () -> project().dashboard(info.id).setDefault());
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
   public void setDefaultDashboardByProject() throws Exception {
     DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().defaultDashboard(info.id);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG,
+        "Administrator",
+        String.format("Changed default dashboard to %s.", info.id));
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
     assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
 
     project().removeDefaultDashboard();
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Removed default dashboard.");
     assertThat(project().dashboard(info.id).get().isDefault).isNull();
 
     assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
diff --git a/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java
new file mode 100644
index 0000000..8e64325
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class LabelsReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void createLabelsChangeWithDefaultMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput);
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).labelsReview(input);
+
+    assertThat(changeInfo.subject).isEqualTo("Review labels change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getStringList("label", "Foo", "value"))
+        .asList()
+        .containsExactly("+1 Looks Good", "0 Don't Know", "-1 Looks Bad");
+  }
+
+  @Test
+  public void createLabelsChangeWithCustomMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput);
+    String customMessage = "test custom message";
+    input.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).labelsReview(input);
+
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getStringList("label", "Foo", "value"))
+        .asList()
+        .containsExactly("+1 Looks Good", "0 Don't Know", "-1 Looks Bad");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index e34b985..80d6b5c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -70,6 +70,7 @@
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -393,6 +394,17 @@
     assertThat(info.state).isEqualTo(input.state);
   }
 
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setConfigRejected() {
+    ConfigInput input = createTestConfigInput();
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).config(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
   @SuppressWarnings("deprecation")
   @Test
   public void setPartialConfig() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
new file mode 100644
index 0000000..34af339
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import org.junit.Test;
+
+public class PutDescriptionIT extends AbstractDaemonTest {
+  @Test
+  public void setDescription() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description";
+    gApi.projects().name(project.get()).description(input);
+    assertThat(gApi.projects().name(project.get()).description())
+        .isEqualTo("test project description");
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Update description");
+  }
+
+  @Test
+  public void setDescriptionWithCustomCommitMessage() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description with test commit message";
+    input.commitMessage = "test commit message";
+    gApi.projects().name(project.get()).description(input);
+    assertThat(gApi.projects().name(project.get()).description())
+        .isEqualTo("test project description with test commit message");
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "test commit message");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setDescription() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description";
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).description(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 2bdbe50..8e1cbe5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -25,8 +25,10 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -89,12 +91,16 @@
 
     gApi.projects().name(project.get()).parent(parent);
     assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", String.format("Changed parent to %s.", parent));
 
     // When the parent name is not explicitly set, it should be
     // set to "All-Projects".
     gApi.projects().name(project.get()).parent(null);
     assertThat(gApi.projects().name(project.get()).parent())
         .isEqualTo(AllProjectsNameProvider.DEFAULT);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Changed parent to All-Projects.");
   }
 
   @Test
@@ -169,4 +175,16 @@
             BadRequestException.class, () -> gApi.projects().name(allUsers.get()).parent(parent));
     assertThat(thrown).hasMessageThat().contains("All-Users must inherit from All-Projects");
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_postParentRejected() {
+    String parent = projectOperations.newProject().create().get();
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).parent(parent));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
index e388dd1..933b538 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -20,15 +20,21 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 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.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
@@ -582,7 +588,7 @@
 
     infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
 
-    assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+    assertThat(names(infos)).containsExactly("No-Unresolved-Comments", "base-sr", "sr-1", "sr-2");
   }
 
   @Test
@@ -639,6 +645,53 @@
     assertThat(names(infos)).containsExactly("verified");
   }
 
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_createSubmitRequirementRejected() {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").create(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_updateSubmitRequirementRejected() throws Exception {
+    createSubmitRequirementWithReview(project.get(), "code-review");
+
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_deleteSubmitRequirementRejected() throws Exception {
+    createSubmitRequirementWithReview(project.get(), "code-review");
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").delete());
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
   private SubmitRequirementInfo createSubmitRequirement(String srName) throws RestApiException {
     return createSubmitRequirement(project.get(), srName);
   }
@@ -652,6 +705,21 @@
     return gApi.projects().name(project).submitRequirement(srName).create(input).get();
   }
 
+  private void createSubmitRequirementWithReview(String project, String srName)
+      throws RestApiException {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = srName;
+    input.submittabilityExpression = "label:dummy=+2";
+    BatchSubmitRequirementInput batchInput = new BatchSubmitRequirementInput();
+    batchInput.create = ImmutableList.of(input);
+
+    ChangeInfo change = gApi.projects().name(project).submitRequirementsReview(batchInput);
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.label("Code-Review", 2);
+    gApi.changes().id(change.project, change._number).current().review(reviewInput);
+    gApi.changes().id(change.project, change._number).current().submit();
+  }
+
   private List<String> names(List<SubmitRequirementInfo> infos) {
     return infos.stream().map(sr -> sr.name).collect(Collectors.toList());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
new file mode 100644
index 0000000..8c8e7a6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class SubmitRequirementsReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void createSubmitRequirementsChangeWithDefaultMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+
+    assertThat(changeInfo.subject).isEqualTo("Review submit requirements change");
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("submit-requirement", "Foo", "description"))
+        .isEqualTo("SR description");
+    assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+        .isEqualTo("topic:foo");
+    assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+        .isEqualTo("label:code-review=+2");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_batchUpdateRejected() {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(testProject.get()).submitRequirements(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
+
+  @Test
+  public void createSubmitRequirementsChangeWithCustomMessage() throws Exception {
+    Project.NameKey testProject = projectOperations.newProject().create();
+    SubmitRequirementInput fooSR = new SubmitRequirementInput();
+    fooSR.name = "Foo";
+    fooSR.description = "SR description";
+    fooSR.applicabilityExpression = "topic:foo";
+    fooSR.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooSR);
+    String customMessage = "test custom message";
+    input.commitMessage = customMessage;
+
+    ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+    assertThat(changeInfo.subject).isEqualTo(customMessage);
+
+    Config config = new Config();
+    config.fromText(
+        gApi.changes()
+            .id(changeInfo.changeId)
+            .revision(1)
+            .file("project.config")
+            .content()
+            .asString());
+    assertThat(config.getString("submit-requirement", "Foo", "description"))
+        .isEqualTo("SR description");
+    assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+        .isEqualTo("topic:foo");
+    assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+        .isEqualTo("label:code-review=+2");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
index b9ef0bf..d66a0a5 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
@@ -357,6 +357,142 @@
   }
 
   @Test
+  public void applyProvidedFixesOnNewerPatchsetWithModifiedFile() throws Exception {
+    // Remember patch set and add another one.
+    int previousRevision = gApi.changes().id(changeId).get().currentRevisionNumber;
+    amendChange(
+        changeId,
+        "refs/for/master",
+        admin,
+        testRepo,
+        PushOneCommit.SUBJECT,
+        FILE_NAME,
+        "New line at the start\n" + FILE_CONTENT);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    applyProvidedFixInput.originalPatchsetForFix = previousRevision;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "New line at the start\nFirst line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+
+    applyProvidedFixInput = createApplyProvidedFixInput(FILE_NAME, "(1st)", 1, 5, 1, 5);
+    applyProvidedFixInput.originalPatchsetForFix = previousRevision;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "New line at the start\nFirst(1st) line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixWithTwoFilesOnNewerPatchsetWithModifiedFile() throws Exception {
+    // Remember patch set and add another one.
+    int previousRevision = gApi.changes().id(changeId).get().currentRevisionNumber;
+    // Remove first 2 lines;
+    String modifiedContent =
+        FILE_CONTENT.substring(FILE_CONTENT.indexOf("\n", FILE_CONTENT.indexOf("\n") + 1) + 1);
+    amendChange(
+        changeId,
+        "refs/for/master",
+        admin,
+        testRepo,
+        PushOneCommit.SUBJECT,
+        FILE_NAME,
+        modifiedContent);
+    amendChange(
+        changeId,
+        "refs/for/master",
+        admin,
+        testRepo,
+        PushOneCommit.SUBJECT,
+        FILE_NAME2,
+        "New line at the start\n" + FILE_CONTENT2);
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(10, 0, 10, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+
+    applyProvidedFixInput.fixReplacementInfos =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    applyProvidedFixInput.originalPatchsetForFix = previousRevision;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "Third line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nFirst modification\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("New line at the start\nDifferent file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void applyProvidedFixOnCommitMessageCanBeAppliedToNewerPatchset() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    int previousRevision = gApi.changes().id(changeId).get().currentRevisionNumber;
+    // Upload a new patchset with the same commit message.
+    amendChange(changeId, originalCommitMessage, FILE_NAME, "a" + FILE_CONTENT);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 7, 0, 8, 0);
+    applyProvidedFixInput.originalPatchsetForFix = previousRevision;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line\nLine 2 of commit message\n" + footer);
+  }
+
+  @Test
+  public void applyProvidedFixOnCommitMessageRejectedIfNewerPatchsetHasDifferentCommitMessage()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    int previousRevision = gApi.changes().id(changeId).get().currentRevisionNumber;
+    // Upload a new patchset with the same commit message.
+    amendChange(changeId, "a" + originalCommitMessage, FILE_NAME, "a" + FILE_CONTENT);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 7, 0, 8, 0);
+    applyProvidedFixInput.originalPatchsetForFix = previousRevision;
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("commit message has been updated in a newer patchset");
+  }
+
+  @Test
   public void applyProvidedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
       throws Exception {
     // Create an empty change edit.
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/CommentWithFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/CommentWithFixIT.java
index d411a21..1918395 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/CommentWithFixIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/CommentWithFixIT.java
@@ -52,7 +52,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.testing.BinaryResultSubject;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestCommentHelper;
@@ -72,7 +71,6 @@
   @Inject private ChangeOperations changeOperations;
   @Inject private AccountOperations accountOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private ExperimentFeatures experimentFeatures;
 
   private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
   private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
index c257e703..c5dba34 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import java.util.Arrays;
 import java.util.List;
@@ -169,6 +170,19 @@
   }
 
   @Test
+  public void previewFixForDifferentPatchset() throws Exception {
+    int previousRevision = gApi.changes().id(changeId).get().currentRevisionNumber;
+    amendChange(changeId);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content\n", 1, 0, 2, 0);
+    applyProvidedFixInput.originalPatchsetForFix = previousRevision;
+
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput));
+  }
+
+  @Test
   public void previewFixForCommitMsg() throws Exception {
     String footer = "Change-Id: " + changeId;
     updateCommitMessage(
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index e4d4610..e07325d 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -588,7 +588,7 @@
     assertThat(cherryInfo._number).isEqualTo(change.get()._number);
     assertThat(cherryInfo.cherryPickOfPatchSet).isEqualTo(1);
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
+    assertThat(cherryIt.next().message).isEqualTo("Patch Set 2: Cherry Picked from branch master.");
   }
 
   @Test
@@ -624,7 +624,7 @@
     assertThat(cherryInfo.messages).hasSize(2);
     Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
+    assertThat(cherryIt.next().message).isEqualTo("Patch Set 2: Cherry Picked from branch master.");
 
     // Parent of change 2 should now be the change that was merged, i.e.
     // change 2 is rebased onto the head of the master branch.
@@ -786,6 +786,31 @@
   }
 
   @Test
+  public void cherryPickToExistingChangeWithAllowConflictsSetsWIPOnConflict() throws Exception {
+    String tip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+    PushOneCommit.Result existingChange =
+        createChange(testRepo, destBranch, SUBJECT, FILE_NAME, "some content", null);
+
+    testRepo.reset(tip);
+    PushOneCommit.Result srcChange =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, "other content", null);
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.base = existingChange.getCommit().name();
+    input.message = "cherry-pick to foo" + "\n\nChange-Id: " + existingChange.getChangeId();
+    input.allowConflicts = true;
+    ChangeInfo changeInfo =
+        change(srcChange).revision(srcChange.getCommit().name()).cherryPickAsInfo(input);
+
+    assertThat(changeInfo.containsGitConflicts).isTrue();
+    assertThat(changeInfo.workInProgress).isTrue();
+  }
+
+  @Test
   public void cherryPickWithValidationOptions() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 5322785d..10d1c77 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 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.RobotCommentInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -1644,6 +1645,35 @@
   }
 
   @Test
+  public void previewStoredFix_additionalCommentWithNullFixes_noException() throws Exception {
+    FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
+    fixReplacementInfoFile1.path = FILE_NAME;
+    fixReplacementInfoFile1.replacement = "some replacement code";
+    fixReplacementInfoFile1.range = createRange(3, 9, 8, 4);
+
+    fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfoFile1);
+
+    withFixRobotCommentInput =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+
+    ReviewInput.CommentInput commentWithoutFix = new CommentInput();
+    commentWithoutFix.message = "Hello";
+    commentWithoutFix.patchSet = 1;
+    commentWithoutFix.path = FILE_NAME;
+    testCommentHelper.addComment(changeId, commentWithoutFix);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    Map<String, DiffInfo> fixPreview = gApi.changes().id(changeId).current().getFixPreview(fixId);
+
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(FILE_NAME);
+  }
+
+  @Test
   public void previewStoredFixAddNewLineAtEnd() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = FILE_NAME3;
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 62a095f..8d86b1e 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -54,6 +55,7 @@
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.changes.RebaseChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
@@ -72,8 +74,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.restapi.change.ChangeEdits;
@@ -103,9 +107,12 @@
   private static final String FILE_NAME = "foo";
   private static final String FILE_NAME2 = "foo2";
   private static final String FILE_NAME3 = "foo3";
+  private static final String FILE_NAME4 = "foo4";
   private static final int FILE_MODE = 100644;
-  private static final byte[] CONTENT_OLD = "bar".getBytes(UTF_8);
-  private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
+  private static final String CONTENT_OLD_STR = "bar";
+  private static final byte[] CONTENT_OLD = CONTENT_OLD_STR.getBytes(UTF_8);
+  private static final String CONTENT_NEW_STR = "baz";
+  private static final byte[] CONTENT_NEW = CONTENT_NEW_STR.getBytes(UTF_8);
   private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
   private static final byte[] CONTENT_NEW2 = CONTENT_NEW2_STR.getBytes(UTF_8);
   private static final String CONTENT_BINARY_ENCODED_NEW =
@@ -268,12 +275,145 @@
     Optional<EditInfo> originalEdit = getEdit(changeId2);
     assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     Timestamp beforeRebase = originalEdit.get().commit.committer.date;
-    gApi.changes().id(changeId2).edit().rebase();
+    EditInfo rebasedEdit = gApi.changes().id(changeId2).edit().rebase(new RebaseChangeEditInput());
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
-    Optional<EditInfo> rebasedEdit = getEdit(changeId2);
-    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.commitId().name());
-    assertThat(rebasedEdit).value().commit().committer().date().isNotEqualTo(beforeRebase);
+    assertThat(rebasedEdit).baseRevision().isEqualTo(currentPatchSet.commitId().name());
+    assertThat(rebasedEdit).commit().committer().date().isNotEqualTo(beforeRebase);
+  }
+
+  @Test
+  public void rebaseEditWithConflictsFails() throws Exception {
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+
+    // add new patch set that touches the same file as the edit
+    addNewPatchSetWithModifiedFile(changeId2, FILE_NAME, new String(CONTENT_NEW2, UTF_8));
+
+    Optional<EditInfo> originalEdit = getEdit(changeId2);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
+
+    MergeConflictException exception =
+        assertThrows(
+            MergeConflictException.class, () -> gApi.changes().id(changeId2).edit().rebase());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Rebasing change edit onto another patchset results in merge conflicts.\n\n"
+                    + "merge conflict(s):\n%s\n\n"
+                    + "Download the edit patchset and rebase manually to preserve changes.",
+                FILE_NAME));
+  }
+
+  @Test
+  public void rebaseEditWithConflictsAllowed() throws Exception {
+    // Create change where FILE_NAME has OLD_CONTENT
+    String changeId = newChange(admin.newIdent());
+
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+
+    // add new patch set that touches the same file as the edit
+    addNewPatchSetWithModifiedFile(changeId, FILE_NAME, new String(CONTENT_NEW2, UTF_8));
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId);
+
+    Optional<EditInfo> originalEdit = getEdit(changeId);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
+
+    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
+
+    RebaseChangeEditInput input = new RebaseChangeEditInput();
+    input.allowConflicts = true;
+    EditInfo rebasedEdit = gApi.changes().id(changeId).edit().rebase(input);
+
+    ensureSameBytes(
+        getFileContentOfEdit(changeId, FILE_NAME),
+        String.format(
+                "<<<<<<< PATCH SET (%s %s)\n"
+                    + "%s\n"
+                    + "=======\n"
+                    + "%s\n"
+                    + ">>>>>>> EDIT      (%s %s)\n",
+                ObjectIds.abbreviateName(currentPatchSet.commitId(), 6),
+                gApi.changes().id(changeId).get().subject,
+                CONTENT_NEW2_STR,
+                CONTENT_NEW_STR,
+                ObjectIds.abbreviateName(ObjectId.fromString(originalEdit.get().commit.commit), 6),
+                originalEdit.get().commit.subject)
+            .getBytes(UTF_8));
+    assertThat(rebasedEdit).baseRevision().isEqualTo(currentPatchSet.commitId().name());
+    assertThat(rebasedEdit).commit().committer().date().isNotEqualTo(beforeRebase);
+    assertThat(rebasedEdit).containsGitConflicts().isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.diff3ConflictView", value = "true")
+  public void rebaseEditWithConflictsAllowedUsingDiff3() throws Exception {
+    // Create change where FILE_NAME has OLD_CONTENT
+    String changeId = newChange(admin.newIdent());
+
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+
+    // add new patch set that touches the same file as the edit
+    addNewPatchSetWithModifiedFile(changeId, FILE_NAME, new String(CONTENT_NEW2, UTF_8));
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId);
+
+    Optional<EditInfo> originalEdit = getEdit(changeId);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
+
+    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
+
+    RebaseChangeEditInput input = new RebaseChangeEditInput();
+    input.allowConflicts = true;
+    EditInfo rebasedEdit = gApi.changes().id(changeId).edit().rebase(input);
+
+    ensureSameBytes(
+        getFileContentOfEdit(changeId, FILE_NAME),
+        String.format(
+                "<<<<<<< PATCH SET (%s %s)\n"
+                    + "%s\n"
+                    + "||||||| BASE\n"
+                    + "%s\n"
+                    + "=======\n"
+                    + "%s\n"
+                    + ">>>>>>> EDIT      (%s %s)\n",
+                ObjectIds.abbreviateName(currentPatchSet.commitId(), 6),
+                gApi.changes().id(changeId).get().subject,
+                CONTENT_NEW2_STR,
+                CONTENT_OLD_STR,
+                CONTENT_NEW_STR,
+                ObjectIds.abbreviateName(ObjectId.fromString(originalEdit.get().commit.commit), 6),
+                originalEdit.get().commit.subject)
+            .getBytes(UTF_8));
+    assertThat(rebasedEdit).baseRevision().isEqualTo(currentPatchSet.commitId().name());
+    assertThat(rebasedEdit).commit().committer().date().isNotEqualTo(beforeRebase);
+    assertThat(rebasedEdit).containsGitConflicts().isTrue();
+  }
+
+  @Test
+  public void rebaseEditWithConflictsAllowedNoConflicts() throws Exception {
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    addNewPatchSet(changeId2);
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
+
+    Optional<EditInfo> originalEdit = getEdit(changeId2);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
+    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
+    RebaseChangeEditInput input = new RebaseChangeEditInput();
+    input.allowConflicts = true;
+    EditInfo rebasedEdit = gApi.changes().id(changeId2).edit().rebase(input);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
+    assertThat(rebasedEdit).baseRevision().isEqualTo(currentPatchSet.commitId().name());
+    assertThat(rebasedEdit).commit().committer().date().isNotEqualTo(beforeRebase);
+    assertThat(rebasedEdit).containsGitConflicts().isFalse();
   }
 
   @Test
@@ -311,7 +451,7 @@
     Optional<EditInfo> originalEdit = getEdit(changeId2);
     assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.commitId().name());
     Timestamp beforeRebase = originalEdit.get().commit.committer.date;
-    adminRestSession.post(urlRebase(changeId2)).assertNoContent();
+    adminRestSession.post(urlRebase(changeId2)).assertOK();
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
     ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
     Optional<EditInfo> rebasedEdit = getEdit(changeId2);
@@ -348,6 +488,16 @@
   }
 
   @Test
+  public void updateMultipleExistingFiles() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME2, RawInputUtil.create(CONTENT_NEW));
+    assertThat(getEdit(changeId)).isPresent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME2), CONTENT_NEW);
+  }
+
+  @Test
   public void updateExistingFileAfterUpdatingPreferredEmail() throws Exception {
     String emailOne = "email1@example.com";
     Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
@@ -1182,6 +1332,41 @@
   }
 
   @Test
+  public void addMultipleNewFiles() throws Exception {
+    createEmptyEditFor(changeId);
+    Optional<EditInfo> originalEdit =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(originalEdit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2);
+
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME4, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME4), CONTENT_NEW);
+
+    Optional<EditInfo> adjustedEdit =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(adjustedEdit)
+        .value()
+        .files()
+        .keys()
+        .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2, FILE_NAME3, FILE_NAME4);
+  }
+
+  @Test
   public void addNewFileAndAmend() throws Exception {
     createEmptyEditFor(changeId);
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
@@ -1371,8 +1556,21 @@
 
   @Test
   public void canCombineEdits() throws Exception {
-    createEmptyEditFor(changeId);
+    String baseFileToDelete = "base_file_to_delete";
+    String baseFileToRename = "base_file_to_rename";
+    String baseFileNewName = "base_file_new_name";
+    String currPatchSetFileToRename = "current_patchset_file_to_rename";
+    String currPatchSetFileNewName = "current_patchset_file_new_name";
+    // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
+    testRepo = cloneProject(project);
+    String baseChangeId = newChangeWithFile(admin.newIdent(), baseFileToDelete, "content");
+    addNewPatchSetWithModifiedFile(baseChangeId, baseFileToRename, "content2");
+    gApi.changes().id(baseChangeId).current().review(ReviewInput.approve());
+    gApi.changes().id(baseChangeId).current().submit();
+    String changeId = newChange(admin.newIdent());
+    addNewPatchSetWithModifiedFile(changeId, currPatchSetFileToRename, "content3");
 
+    createEmptyEditFor(changeId);
     // update author
     gApi.changes()
         .id(changeId)
@@ -1394,11 +1592,20 @@
         .modifyIdentity(
             "Test Committer", "test.committer@example.com", ChangeEditIdentityType.COMMITTER);
 
-    // delete file
+    // delete current patch-set file
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
 
-    // rename file
-    gApi.changes().id(changeId).edit().renameFile(FILE_NAME2, FILE_NAME3);
+    // delete base file
+    gApi.changes().id(changeId).edit().deleteFile(baseFileToDelete);
+
+    // rename current patch-set file
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .renameFile(currPatchSetFileToRename, currPatchSetFileNewName);
+
+    // rename base file
+    gApi.changes().id(changeId).edit().renameFile(baseFileToRename, baseFileNewName);
 
     // publish edit
     PublishChangeEditInput publishInput = new PublishChangeEditInput();
@@ -1419,7 +1626,12 @@
     assertThat(currentCommit.author.name).isEqualTo("Test Author");
     assertThat(currentCommit.author.email).isEqualTo("test.author@example.com");
     assertThat(currentCommit.message).isEqualTo(msg);
-    assertThat(currentRevision.files.keySet()).containsExactly(newFile, FILE_NAME3);
+    assertThat(currentRevision.files.keySet())
+        .containsExactly(newFile, baseFileToDelete, baseFileNewName, currPatchSetFileNewName);
+    assertThat(currentRevision.files.get(newFile).status).isEqualTo('A');
+    assertThat(currentRevision.files.get(baseFileToDelete).status).isEqualTo('D');
+    assertThat(currentRevision.files.get(baseFileNewName).status).isEqualTo('R');
+    assertThat(currentRevision.files.get(currPatchSetFileNewName).status).isEqualTo('A');
   }
 
   private void createArbitraryEditFor(String changeId) throws Exception {
@@ -1451,6 +1663,13 @@
     return push.to("refs/for/master").getChangeId();
   }
 
+  private String newChangeWithFile(PersonIdent ident, String filePath, String fileContent)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(ident, testRepo, PushOneCommit.SUBJECT, filePath, fileContent);
+    return push.to("refs/for/master").getChangeId();
+  }
+
   private void addNewPatchSet(String changeId) throws Exception {
     addNewPatchSetWithModifiedFile(changeId, FILE_NAME2, new String(CONTENT_NEW2, UTF_8));
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 8861a9e..34b858e 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1028,6 +1028,19 @@
 
     // verify that the re-indexing was triggered for the change
     assertThat(query("has:edit")).hasSize(1);
+
+    // update the existing edit
+    r = amendChange(r.getChangeId(), "refs/for/master%edit");
+    r.assertOkStatus();
+    r.assertMessage(
+        canonicalWebUrl.get()
+            + "c/"
+            + project.get()
+            + "/+/"
+            + r.getChange().getId()
+            + " "
+            + editInfo.commit.subject
+            + " [EDIT]\n");
   }
 
   @Test
@@ -2660,6 +2673,58 @@
         .isEqualTo(Iterables.getLast(commits).name());
   }
 
+  @Test
+  public void aclInfoIsReturnedIfPushFailsDueToAPermissionError() throws Exception {
+    String master = "refs/heads/master";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref(master).group(REGISTERED_USERS))
+        .update();
+
+    // without VIEW_ACCESS capability no ACL info is returned
+    TestRepository<?> userRepo = cloneProject(project, user);
+    userRepo
+        .branch("HEAD")
+        .commit()
+        .message("New Commit 1")
+        .author(user.newIdent())
+        .committer(user.newIdent())
+        .insertChangeId()
+        .create();
+    PushResult pushResult = pushHead(userRepo, master);
+    assertPushRejected(pushResult, master, "prohibited by Gerrit: not permitted: update");
+    assertThat(pushResult.getMessages()).doesNotContain("ACL info");
+
+    // with VIEW_ACCESS capability an ACL info is returned when the request fails due to a
+    // permission error
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ACCESS).group(REGISTERED_USERS))
+        .update();
+    pushResult = pushHead(userRepo, master);
+    assertPushRejected(pushResult, master, "prohibited by Gerrit: not permitted: update");
+    assertThat(pushResult.getMessages())
+        .contains(
+            String.format(
+                "ACL info:\n"
+                    + "* '%s' cannot perform 'push' with force=false on project '%s'"
+                    + " for ref '%s' because this permission is blocked",
+                user.username(), project, master));
+
+    // with VIEW_ACCESS capability no ACL info is returned when the request doesn't fail due to a
+    // permission error
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(master).group(REGISTERED_USERS))
+        .update();
+    pushResult = pushHead(userRepo, master);
+    assertPushOk(pushResult, master);
+    assertThat(pushResult.getMessages()).doesNotContain("ACL info");
+  }
+
   private static class TestValidator implements CommitValidationListener {
     private final AtomicInteger count = new AtomicInteger();
     private final boolean validateAll;
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT.java
index 78c2786..592220e 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT.java
@@ -18,11 +18,9 @@
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.testing.ConfigSuite;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -78,9 +76,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.REBASE_MERGE_COMMITS)
   public void doesntAddContentFromParentForImplicitMergeChange() throws Exception {
     gApi.changes().id(implicitMergeChangeId).current().submit();
 
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
index 6739071..d85e613 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
@@ -60,7 +60,9 @@
     // uses @RunWith(ConfigSuite.class). Emulate parameters using configs.
     ImmutableMap.Builder<String, Config> builder = ImmutableMap.builder();
     for (SubmitType submitType : SubmitType.values()) {
-      if (submitType == SubmitType.INHERIT || submitType == SubmitType.CHERRY_PICK) {
+      if (submitType == SubmitType.INHERIT
+          || submitType == SubmitType.CHERRY_PICK
+          || submitType == SubmitType.REBASE_ALWAYS) {
         continue;
       }
       Config cfg = new Config();
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index f94aa12..fa6d2e4 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -45,6 +45,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -405,6 +406,33 @@
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
   }
 
+  @Test
+  public void pushTreeIsNotAllowed() throws Exception {
+    RevCommit commit = testRepo.branch("HEAD").commit().create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // We use "refs/main" instead of "refs/heads/main", because the latter only allows commits.
+    {
+      // An extra colon (:) makes it a tree reference
+      PushResult r = push(commit.getId().getName() + "::refs/main");
+      RemoteRefUpdate refUpdate = r.getRemoteUpdates().stream().findFirst().get();
+      assertThat(refUpdate.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
+      assertThat(refUpdate.getMessage()).contains("is neither Commit or Tag");
+    }
+
+    {
+      PushResult r = push(commit.getTree().getId().getName() + ":refs/main");
+      RemoteRefUpdate refUpdate = r.getRemoteUpdates().stream().findFirst().get();
+      assertThat(refUpdate.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
+      assertThat(refUpdate.getMessage()).contains("is neither Commit or Tag");
+    }
+  }
+
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
     for (AccessSection s : cfg.getAccessSections()) {
       if (s.getName().startsWith("refs/heads/")
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 6c5febd..c9f469e 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.schema.SchemaCreatorImpl;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -87,7 +88,8 @@
   @Inject private IndexOperations.Change changeIndexOperations;
 
   private AccountGroup.UUID admins;
-  private AccountGroup.UUID nonInteractiveUsers;
+  private AccountGroup.UUID serviceUsers;
+  private AccountGroup.UUID blockedUsers;
 
   private RevCommit rcMaster;
   private RevCommit rcBranch;
@@ -118,7 +120,8 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+    serviceUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+    blockedUsers = groupUuid(SchemaCreatorImpl.BLOCKED_USERS);
     setUpPermissions();
     setUpChanges();
   }
@@ -1239,7 +1242,8 @@
       assertThat(getGroupRefs(git))
           .containsExactly(
               RefNames.refsGroups(admins),
-              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(serviceUsers),
+              RefNames.refsGroups(blockedUsers),
               RefNames.refsGroups(users));
     }
   }
@@ -1261,7 +1265,8 @@
       assertThat(getGroupRefs(git))
           .containsExactly(
               RefNames.refsGroups(admins),
-              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(serviceUsers),
+              RefNames.refsGroups(blockedUsers),
               RefNames.refsGroups(users));
     }
   }
@@ -1413,7 +1418,8 @@
             RefNames.REFS_EXTERNAL_IDS,
             RefNames.REFS_GROUPNAMES,
             RefNames.refsGroups(admins),
-            RefNames.refsGroups(nonInteractiveUsers),
+            RefNames.refsGroups(serviceUsers),
+            RefNames.refsGroups(blockedUsers),
             RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS,
             RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS,
             RefNames.REFS_CONFIG,
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index c5059c3..66c4078 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.Schema;
@@ -67,9 +68,45 @@
     Files.createDirectory(sitePaths.index_dir);
     assertServerStartupFails();
 
-    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
+    assertReady(ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion());
+    assertIndexQueries();
+  }
+
+  @Test
+  public void reindexWithSkipExistingDocumentsEnabled() throws Exception {
+    updateConfig(config -> config.setBoolean("index", null, "reuseExistingDocuments", true));
+    setUpChange();
+
+    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.createDirectory(sitePaths.index_dir);
+    assertServerStartupFails();
+
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
     assertReady(ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion());
 
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
+    assertIndexQueries();
+
+    Files.copy(sitePaths.index_dir, sitePaths.resolve("index-backup"));
+    try (ServerContext ctx = startServer()) {
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      gApi.changes().id(changeId).revision(1).review(ReviewInput.approve());
+      // Query change index
+      assertThat(gApi.changes().query("label:Code-Review+2").get().stream().map(c -> c.changeId))
+          .containsExactly(changeId);
+    }
+    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.copy(sitePaths.resolve("index-backup"), sitePaths.index_dir);
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
+    try (ServerContext ctx = startServer()) {
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      assertThat(gApi.changes().query("label:Code-Review+2").get().stream().map(c -> c.changeId))
+          .containsExactly(changeId);
+    }
+  }
+
+  private void assertIndexQueries() throws Exception {
     try (ServerContext ctx = startServer()) {
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       // Query change index
diff --git a/javatests/com/google/gerrit/acceptance/rest/AclInfoRestIT.java b/javatests/com/google/gerrit/acceptance/rest/AclInfoRestIT.java
new file mode 100644
index 0000000..24ba1c3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/AclInfoRestIT.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+
+public class AclInfoRestIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ChangeOperations changeOperations;
+
+  @Test
+  public void cannotApplyProvidedFixlWithoutAddPatchSetPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .file("foo.txt")
+            .content("some content")
+            .create();
+
+    Comment.Range range = new Comment.Range();
+    range.startLine = 1;
+    range.startCharacter = 0;
+    range.endLine = 1;
+    range.endCharacter = 3;
+
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = "foo.txt";
+    fixReplacementInfo.replacement = "other";
+    fixReplacementInfo.range = range;
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    // without VIEW_ACCESS capability no ACL info is returned
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/current/fix:apply", applyProvidedFixInput);
+    resp.assertStatus(403);
+    assertThat(resp.getEntityContent()).isEqualTo("edit not permitted");
+
+    // with VIEW_ACCESS capability an ACL info is returned when the request fails due to a
+    // permission error
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ACCESS).group(REGISTERED_USERS))
+        .update();
+    resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/current/fix:apply", applyProvidedFixInput);
+    resp.assertStatus(403);
+    assertThat(resp.getEntityContent())
+        .isEqualTo(
+            String.format(
+                "edit not permitted\n\n"
+                    + "ACL info:\n"
+                    + "* '%s' can perform 'read' with force=false on project '%s'"
+                    + " for ref 'refs/heads/master'"
+                    + " (allowed for group 'global:Anonymous-Users' by rule 'group Anonymous Users')\n"
+                    + "* '%s' can perform 'push' with force=false on project '%s'"
+                    + " for ref 'refs/for/refs/heads/master'"
+                    + " (allowed for group 'global:Registered-Users' by rule 'group Registered Users')\n"
+                    + "* '%s' cannot perform 'addPatchSet' with force=false on project '%s'"
+                    + " for ref 'refs/for/refs/heads/master' because this permission is blocked",
+                user.username(), project, user.username(), project, user.username(), project));
+
+    // with VIEW_ACCESS capability no ACL info is returned when the request doesn't fail due to a
+    // permission error
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ADD_PATCH_SET).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+    resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/current/fix:apply", applyProvidedFixInput);
+    resp.assertOK();
+    assertThat(resp.getEntityContent()).doesNotContain("ACL info");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 5c1f7c2..a54c7ca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.regex.Pattern;
@@ -449,16 +450,38 @@
   }
 
   @Test
+  public void testNumericChangeIdWithExtraSegments() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    int changeNumber = changeData.getId().get();
+
+    assertChangeNumberWithSuffixRedirected(changeNumber, "1..2");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2/COMMIT_MSG");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2?foo=bar");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2/path/to/source/file/MyClass.java");
+  }
+
+  private void assertChangeNumberWithSuffixRedirected(int changeNumber, String suffix)
+      throws Exception {
+    String redirectUri =
+        anonymousRestSession.getUrl(
+            String.format("/c/%s/+/%d/%s", project.get(), changeNumber, suffix));
+    anonymousRestSession
+        .get(String.format("/c/%d/%s", changeNumber, suffix))
+        .assertTemporaryRedirectUri(redirectUri);
+  }
+
+  @Test
   public void testCommentLinkWithoutPrefixRedirects() throws Exception {
     int changeNumber = createChange().getChange().getId().get();
     String commentId = "ff3303fd_8341647b";
 
-    String redirectUri =
+    String redirectPath =
         String.format("/c/%s/+/%d/comment/%s", project.get(), changeNumber, commentId);
 
     anonymousRestSession
         .get(String.format("/%s/comment/%s", changeNumber, commentId))
-        .assertTemporaryRedirect(redirectUri);
+        .assertTemporaryRedirect(redirectPath);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 2d73e97..8f267db 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
@@ -68,6 +69,7 @@
 import java.util.Optional;
 import java.util.Set;
 import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Rule;
 import org.junit.Test;
 
@@ -91,6 +93,7 @@
 
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private WorkQueue workQueue;
+  @Inject private ProjectOperations projectOperations;
 
   @Test
   public void restCallWithoutTrace() throws Exception {
@@ -386,7 +389,8 @@
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new10");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce())
+          .logNanos(anyString(), anyLong(), any());
     }
   }
 
@@ -399,7 +403,8 @@
       PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
-      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce())
+          .logNanos(anyString(), anyLong(), any());
     }
   }
 
@@ -559,7 +564,7 @@
 
   @Test
   @GerritConfig(name = "tracing.issue123.requestType", value = "REST")
-  public void traceRequestType() throws Exception {
+  public void traceConfigWithRequestTypeOnlyDoesntTriggerTracing() throws Exception {
     TraceValidatingProjectCreationValidationListener projectCreationListener =
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
@@ -567,15 +572,18 @@
       RestResponse response = adminRestSession.put("/projects/new19");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
-      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
       assertThat(projectCreationListener.tags.get("project")).containsExactly("new19");
     }
   }
 
   @Test
-  @GerritConfig(name = "tracing.issue123.requestType", value = "SSH")
-  public void traceRequestTypeNoMatch() throws Exception {
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new20")
+  @GerritConfig(name = "tracing.issue123.requestType", value = "REST")
+  public void traceRequestType() throws Exception {
     TraceValidatingProjectCreationValidationListener projectCreationListener =
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
@@ -583,17 +591,16 @@
       RestResponse response = adminRestSession.put("/projects/new20");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(projectCreationListener.traceId).isNull();
-      assertThat(projectCreationListener.isLoggingForced).isFalse();
-
-      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
       assertThat(projectCreationListener.tags.get("project")).containsExactly("new20");
     }
   }
 
   @Test
-  @GerritConfig(name = "tracing.issue123.requestType", value = "FOO")
-  public void traceProjectInvalidRequestType() throws Exception {
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new21")
+  @GerritConfig(name = "tracing.issue123.requestType", value = "SSH")
+  public void traceRequestTypeNoMatch() throws Exception {
     TraceValidatingProjectCreationValidationListener projectCreationListener =
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
@@ -610,9 +617,42 @@
   }
 
   @Test
-  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
-  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
-  public void traceProjectForAccount() throws Exception {
+  @GerritConfig(name = "tracing.issue123.requestType", value = "GIT_RECEIVE")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "traced-project")
+  public void traceGitReceiveForProject() throws Exception {
+    Project.NameKey tracedProject = projectOperations.newProject().name("traced-project").create();
+    TestRepository<?> tracedRepo = cloneProject(tracedProject);
+
+    TraceValidatingCommitValidationListener commitValidationListener =
+        new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), tracedRepo);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isEqualTo("issue123");
+      assertThat(commitValidationListener.isLoggingForced).isTrue();
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(tracedProject.get());
+    }
+
+    // other project is not traced
+    commitValidationListener = new TraceValidatingCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(commitValidationListener.traceId).isNull();
+      assertThat(commitValidationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "FOO")
+  public void traceProjectInvalidRequestType() throws Exception {
     TraceValidatingProjectCreationValidationListener projectCreationListener =
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
@@ -620,9 +660,28 @@
       RestResponse response = adminRestSession.put("/projects/new22");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new22");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectForAccount() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/new23");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(projectCreationListener.traceId).isEqualTo("issue123");
       assertThat(projectCreationListener.isLoggingForced).isTrue();
-      assertThat(projectCreationListener.tags.get("project")).containsExactly("new22");
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
     }
   }
 
@@ -634,14 +693,14 @@
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
-      RestResponse response = adminRestSession.put("/projects/new23");
+      RestResponse response = adminRestSession.put("/projects/new24");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(projectCreationListener.traceId).isNull();
       assertThat(projectCreationListener.isLoggingForced).isFalse();
 
       // The logging tag with the project name is also set if tracing is off.
-      assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
     }
   }
 
@@ -653,14 +712,14 @@
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
-      RestResponse response = adminRestSession.put("/projects/new24");
+      RestResponse response = adminRestSession.put("/projects/new25");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(projectCreationListener.traceId).isNull();
       assertThat(projectCreationListener.isLoggingForced).isFalse();
 
       // The logging tag with the project name is also set if tracing is off.
-      assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new25");
     }
   }
 
@@ -671,12 +730,12 @@
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
-      RestResponse response = adminRestSession.put("/projects/new23");
+      RestResponse response = adminRestSession.put("/projects/new26");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(projectCreationListener.traceId).isEqualTo("issue123");
       assertThat(projectCreationListener.isLoggingForced).isTrue();
-      assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new26");
     }
   }
 
@@ -687,14 +746,14 @@
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
-      RestResponse response = adminRestSession.put("/projects/new23");
+      RestResponse response = adminRestSession.put("/projects/new27");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(projectCreationListener.traceId).isNull();
       assertThat(projectCreationListener.isLoggingForced).isFalse();
 
       // The logging tag with the project name is also set if tracing is off.
-      assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new27");
     }
   }
 
@@ -705,14 +764,14 @@
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
-      RestResponse response = adminRestSession.put("/projects/new24");
+      RestResponse response = adminRestSession.put("/projects/new28");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(projectCreationListener.traceId).isNull();
       assertThat(projectCreationListener.isLoggingForced).isFalse();
 
       // The logging tag with the project name is also set if tracing is off.
-      assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("new28");
     }
   }
 
@@ -736,24 +795,7 @@
 
   @Test
   @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/no-match")
-  public void traceExcludedRequestUriPatternNoMatch() throws Exception {
-    TraceValidatingProjectCreationValidationListener projectCreationListener =
-        new TraceValidatingProjectCreationValidationListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(projectCreationListener)) {
-      RestResponse response = adminRestSession.put("/projects/xyz3");
-      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
-      assertThat(projectCreationListener.isLoggingForced).isTrue();
-      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz3");
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
-  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/xyz2")
-  public void traceRequestUriPatternAndExcludedRequestUriPattern() throws Exception {
+  public void traceConfigWithExcludedRequestUriPatternOnlyDoesntTriggerTracing() throws Exception {
     TraceValidatingProjectCreationValidationListener projectCreationListener =
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
@@ -770,9 +812,9 @@
   }
 
   @Test
-  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "xyz3")
   @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/no-match")
-  public void traceRequestUriPatternAndExcludedRequestUriPatternNoMatch() throws Exception {
+  public void traceExcludedRequestUriPatternNoMatch() throws Exception {
     TraceValidatingProjectCreationValidationListener projectCreationListener =
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
@@ -787,8 +829,9 @@
   }
 
   @Test
-  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "][")
-  public void traceExcludedRequestUriInvalidRegEx() throws Exception {
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/xyz4")
+  public void traceRequestUriPatternAndExcludedRequestUriPattern() throws Exception {
     TraceValidatingProjectCreationValidationListener projectCreationListener =
         new TraceValidatingProjectCreationValidationListener();
     try (Registration registration =
@@ -805,7 +848,59 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/no-match")
+  public void traceRequestUriPatternAndExcludedRequestUriPatternNoMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/xyz5");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz5");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "][")
+  public void traceExcludedRequestUriInvalidRegEx() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/xyz6");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz6");
+    }
+  }
+
+  @Test
   @GerritConfig(name = "tracing.issue123.requestQueryStringPattern", value = ".*limit=.*")
+  public void traceConfigWithRequestQueryStringOnlyDoesntTriggerTracing() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+      RestResponse response =
+          adminRestSession.get(String.format("/changes/%s/suggest_reviewers?limit=10", changeId));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isNull();
+      assertThat(reviewerSuggestion.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestQueryStringPattern", value = ".*limit=.*")
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/changes/.*")
   public void traceRequestQueryString() throws Exception {
     String changeId = createChange().getChangeId();
     TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
@@ -822,6 +917,7 @@
 
   @Test
   @GerritConfig(name = "tracing.issue123.requestQueryStringPattern", value = ".*query=.*")
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/changes/.*")
   public void traceRequestQueryStringNoMatch() throws Exception {
     String changeId = createChange().getChangeId();
     TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
@@ -838,6 +934,7 @@
 
   @Test
   @GerritConfig(name = "tracing.issue123.requestQueryStringPattern", value = "][")
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/changes/.*")
   public void traceRequestQueryStringInvalidRegEx() throws Exception {
     String changeId = createChange().getChangeId();
     TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
@@ -854,6 +951,27 @@
 
   @Test
   @GerritConfig(name = "tracing.issue123.headerPattern", value = "User-Agent=foo.*")
+  public void traceConfigWithHeaderOnlyDoesntTriggerTracing() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+
+      RestResponse response =
+          adminRestSession.getWithHeaders(
+              String.format("/changes/%s/suggest_reviewers?limit=10", changeId),
+              new BasicHeader("User-Agent", "foo-bar"),
+              new BasicHeader("Other-Header", "baz"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isNull();
+      assertThat(reviewerSuggestion.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.headerPattern", value = "User-Agent=foo.*")
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/changes/.*")
   public void traceHeader() throws Exception {
     String changeId = createChange().getChangeId();
     TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
@@ -928,6 +1046,7 @@
   }
 
   @Test
+  @GerritConfig(name = "retry.timeout", value = "1s")
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
   public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
     String changeId = createChange().getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 2e706b8..7de689d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -689,6 +689,15 @@
   }
 
   @Test
+  @UseLocalDisk
+  public void submitOnBehalfOf_usersSubmitPermissionIgnored() throws Exception {
+    // user is part of the newGroup, block their submit permission
+    blockSubmit(newGroup);
+
+    testSubmitOnBehalfOf(project, admin2, user);
+  }
+
+  @Test
   public void submitOnBehalfOfFailsWhenUserCannotSeeDestinationRef() throws Exception {
     blockRead(newGroup);
 
@@ -926,6 +935,14 @@
         .update();
   }
 
+  private void blockSubmit(GroupInfo group) throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/heads/*").group(AccountGroup.uuid(group.id)))
+        .update();
+  }
+
   private void allowRunAs() throws Exception {
     projectOperations
         .allProjectsForUpdate()
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
index cfee7da..a58b30f3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
@@ -104,6 +104,7 @@
           RestCall.get("/accounts/%s/sshkeys/"),
           RestCall.post("/accounts/%s/sshkeys/"),
           RestCall.get("/accounts/%s/starred.changes"),
+          RestCall.get("/accounts/%s/state"),
           RestCall.get("/accounts/%s/status"),
           RestCall.put("/accounts/%s/status"),
           RestCall.get("/accounts/%s/username"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 576a921..bfc64a6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
@@ -51,6 +52,7 @@
           RestCall.get("/config/server/capabilities"),
           RestCall.post("/config/server/check.consistency"),
           RestCall.put("/config/server/email.confirm"),
+          RestCall.get("/config/server/experiments"),
           RestCall.post("/config/server/index.changes"),
           RestCall.get("/config/server/info"),
           RestCall.get("/config/server/preferences"),
@@ -61,9 +63,11 @@
           RestCall.put("/config/server/preferences.edit"),
           RestCall.post("/config/server/reload"),
           RestCall.get("/config/server/summary"),
+          RestCall.post("/config/server/deactivate.stale.accounts"),
           RestCall.get("/config/server/tasks"),
           RestCall.get("/config/server/top-menus"),
-          RestCall.get("/config/server/version"));
+          RestCall.get("/config/server/version"),
+          RestCall.post("/config/server/cleanup.changes"));
 
   /**
    * Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
@@ -73,6 +77,13 @@
       ImmutableList.of(RestCall.get("/config/server/caches/%s"));
 
   /**
+   * Experiment REST endpoints to be tested, the URLs contain a placeholder for the experiment name.
+   * Since there is only a single supported config identifier ('server') it can be hard-coded.
+   */
+  private static final ImmutableList<RestCall> EXPERIMENT_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/config/server/experiments/%s"));
+
+  /**
    * Task REST endpoints to be tested, the URLs contain a placeholder for the task identifier. Since
    * there is only a single supported config identifier ('server') it can be hard-coded.
    */
@@ -102,6 +113,14 @@
   }
 
   @Test
+  public void experimentEndpoints() throws Exception {
+    RestApiCallHelper.execute(
+        adminRestSession,
+        EXPERIMENT_ENDPOINTS,
+        ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS);
+  }
+
+  @Test
   public void taskEndpoints() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result =
@@ -110,7 +129,7 @@
 
     Optional<String> id =
         result.stream()
-            .filter(t -> "Log File Compressor".equals(t.command))
+            .filter(t -> "Log File Manager".equals(t.command))
             .map(t -> t.id)
             .findFirst();
     assertThat(id).isPresent();
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index db9c1e7..18c435f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -70,6 +70,7 @@
           RestCall.get("/projects/%s/commits:in"),
           RestCall.get("/projects/%s/config"),
           RestCall.put("/projects/%s/config"),
+          RestCall.put("/projects/%s/config:review"),
           RestCall.post("/projects/%s/create.change"),
           RestCall.get("/projects/%s/dashboards"),
           RestCall.get("/projects/%s/description"),
@@ -82,12 +83,15 @@
           RestCall.post("/projects/%s/index.changes"),
           RestCall.get("/projects/%s/labels"),
           RestCall.post("/projects/%s/labels/"),
+          RestCall.post("/projects/%s/labels:review"),
           RestCall.put("/projects/%s/labels/new-label"),
           RestCall.get("/projects/%s/parent"),
           RestCall.put("/projects/%s/parent"),
           RestCall.get("/projects/%s/statistics.git"),
           RestCall.get("/projects/%s/submit_requirements"),
           RestCall.put("/projects/%s/submit_requirements/new-sr"),
+          RestCall.post("/projects/%s/submit_requirements/"),
+          RestCall.post("/projects/%s/submit_requirements:review"),
           RestCall.get("/projects/%s/tags"),
           RestCall.put("/projects/%s/tags/new-tag"),
           RestCall.post("/projects/%s/tags:delete"));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 9723df1..0b86406 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -37,6 +37,7 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import com.github.rholder.retry.RetryException;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
@@ -92,7 +93,6 @@
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitInput;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -589,7 +589,7 @@
             + num
             + ": Change "
             + num
-            + " is work in progress");
+            + " is marked work in progress");
   }
 
   @Test
@@ -607,7 +607,7 @@
               + num
               + ": Change "
               + num
-              + " is work in progress");
+              + " is marked work in progress");
     }
   }
 
@@ -688,36 +688,6 @@
 
     List<RevCommit> log = getRemoteLog();
     assertThat(log).contains(stable.getCommit());
-    assertThat(log).contains(mergeReview.getCommit());
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.REBASE_MERGE_COMMITS)
-  public void submitMergeOfNonChangeBranchTip_withRebasingMergeCommits() throws Throwable {
-    // Merge a branch with commits that have not been submitted as
-    // changes.
-    //
-    // M  -- mergeCommit (pushed for review and submitted)
-    // | \
-    // |  S -- stable (pushed directly to refs/heads/stable)
-    // | /
-    // I   -- master
-    //
-    RevCommit master = projectOperations.project(project).getHead("master");
-    PushOneCommit stableTip =
-        pushFactory.create(admin.newIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
-    PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
-    PushOneCommit mergeCommit =
-        pushFactory.create(admin.newIdent(), testRepo, "The merge commit", "merge.txt", "");
-    mergeCommit.setParents(ImmutableList.of(master, stable.getCommit()));
-    PushOneCommit.Result mergeReview = mergeCommit.to("refs/for/master");
-    approve(mergeReview.getChangeId());
-    submit(mergeReview.getChangeId());
-
-    List<RevCommit> log = getRemoteLog();
-    assertThat(log).contains(stable.getCommit());
 
     if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       // the merge commit has been rebased
@@ -772,53 +742,6 @@
 
     List<RevCommit> log = getRemoteLog();
     assertThat(log).contains(s1.getCommit());
-    assertThat(log).contains(mergeReview.getCommit());
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.REBASE_MERGE_COMMITS)
-  public void submitMergeOfNonChangeBranchNonTip_withRebasingMergeCommits() throws Throwable {
-    // Merge a branch with commits that have not been submitted as
-    // changes.
-    //
-    // MC  -- merge commit (pushed for review and submitted)
-    // |\   S2 -- new stable tip (pushed directly to refs/heads/stable)
-    // M \ /
-    // |  S1 -- stable (pushed directly to refs/heads/stable)
-    // | /
-    // I -- master
-    //
-    RevCommit initial = projectOperations.project(project).getHead("master");
-    // push directly to stable to S1
-    PushOneCommit.Result s1 =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "new commit into stable", "stable1.txt", "")
-            .to("refs/heads/stable");
-    // move the stable tip ahead to S2
-    pushFactory
-        .create(admin.newIdent(), testRepo, "Tip of branch stable", "stable2.txt", "")
-        .to("refs/heads/stable");
-
-    testRepo.reset(initial);
-
-    // move the master ahead
-    PushOneCommit.Result m =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "Move master ahead", "master.txt", "")
-            .to("refs/heads/master");
-
-    // create merge change
-    PushOneCommit mc =
-        pushFactory.create(admin.newIdent(), testRepo, "The merge commit", "merge.txt", "");
-    mc.setParents(ImmutableList.of(m.getCommit(), s1.getCommit()));
-    PushOneCommit.Result mergeReview = mc.to("refs/for/master");
-    approve(mergeReview.getChangeId());
-    submit(mergeReview.getChangeId());
-
-    List<RevCommit> log = getRemoteLog();
-    assertThat(log).contains(s1.getCommit());
 
     if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       // the merge commit has been rebased
@@ -1058,81 +981,6 @@
     assertMerged(mergeId);
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(projectOperations.project(project).getHead("master"));
-    assertThat(rw.isMergedInto(merge, master)).isTrue();
-    assertThat(rw.isMergedInto(fix, master)).isTrue();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.REBASE_MERGE_COMMITS)
-  public void submitWithCommitAndItsMergeCommitTogether_withRebasingMergeCommits()
-      throws Throwable {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-
-    // Create a stable branch and bootstrap it.
-    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
-    PushOneCommit push =
-        pushFactory.create(user.newIdent(), testRepo, "initial commit", "a.txt", "a");
-    PushOneCommit.Result change = push.to("refs/heads/stable");
-
-    RevCommit stable = projectOperations.project(project).getHead("stable");
-    RevCommit master = projectOperations.project(project).getHead("master");
-
-    assertThat(master).isEqualTo(initialHead);
-    assertThat(stable).isEqualTo(change.getCommit());
-
-    testRepo.git().fetch().call();
-    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
-    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
-
-    // Create a fix in stable branch.
-    testRepo.reset(stable);
-    RevCommit fix =
-        testRepo
-            .commit()
-            .parent(stable)
-            .message("small fix")
-            .add("b.txt", "b")
-            .insertChangeId()
-            .create();
-    testRepo.branch("refs/heads/stable").update(fix);
-    testRepo
-        .git()
-        .push()
-        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable%topic=" + name("topic")))
-        .call();
-
-    // Merge the fix into master.
-    testRepo.reset(master);
-    RevCommit merge =
-        testRepo
-            .commit()
-            .parent(master)
-            .parent(fix)
-            .message("Merge stable into master")
-            .insertChangeId()
-            .create();
-    testRepo.branch("refs/heads/master").update(merge);
-    testRepo
-        .git()
-        .push()
-        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master%topic=" + name("topic")))
-        .call();
-
-    // Submit together.
-    String fixId = GitUtil.getChangeId(testRepo, fix).get();
-    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
-    approve(fixId);
-    approve(mergeId);
-    submit(mergeId);
-    assertMerged(fixId);
-    assertMerged(mergeId);
-    testRepo.git().fetch().call();
-    RevWalk rw = testRepo.getRevWalk();
     RevCommit newMaster = rw.parseCommit(projectOperations.project(project).getHead("master"));
     assertThat(rw.isMergedInto(fix, newMaster)).isTrue();
 
@@ -1190,7 +1038,11 @@
     testMetricMaker.reset();
 
     Throwable thrown = assertThrows(StorageException.class, () -> submit(id, input));
-    assertThat(thrown.getCause()).hasMessageThat().contains("missing from ChangeSet[][]");
+    assertThat(thrown.getCause()).hasMessageThat().contains("Computing mergeSuperset has failed");
+    assertThat(thrown.getCause()).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(thrown.getCause().getCause().getCause())
+        .hasMessageThat()
+        .contains("missing from ChangeSet[][]");
 
     // We retried more than once before giving up
     assertThat(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 4fbdab4..c34625e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
@@ -38,7 +37,6 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
@@ -173,68 +171,6 @@
   @Test
   public void submitMergeCommitThatDependsOnNormalChangeViaTheFirstParent() throws Throwable {
     /*
-           *  merge created by Gerrit to integrate change 1 and change 2
-          /|
-         / |
-         | |
-         * | change3 (new tip)
-         | |
-         | * change2 (merge)
-         | |\
-         | | |
-         | * | change1
-          \|/
-           * initialHead
-    */
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
-
-    PushOneCommit change2Push =
-        pushFactory.create(admin.newIdent(), testRepo, "Merge to master", "m.txt", "");
-    change2Push.setParents(ImmutableList.of(change1.getCommit(), initialHead));
-    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("New tip", "b.txt", "");
-
-    approve(change3.getChangeId());
-    submit(change3.getChangeId());
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    submit(change2.getChangeId());
-
-    RevCommit newHead = projectOperations.project(project).getHead("master");
-    assertThat(newHead.getParentCount()).isEqualTo(2);
-
-    RevCommit headParent1 = parse(newHead.getParent(0).getId());
-    RevCommit headParent2 = parse(newHead.getParent(1).getId());
-
-    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      assertCurrentRevision(change3.getChangeId(), 2, headParent1.getId());
-    } else {
-      assertThat(change3.getCommit().getId()).isEqualTo(headParent1.getId());
-    }
-    assertThat(headParent1.getParentCount()).isEqualTo(1);
-    assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
-
-    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
-    assertThat(headParent2.getParentCount()).isEqualTo(2);
-
-    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
-    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
-
-    assertThat(headGrandparent1.getId()).isEqualTo(change1.getCommit().getId());
-    assertThat(headGrandparent2.getId()).isEqualTo(initialHead.getId());
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.REBASE_MERGE_COMMITS)
-  public void submitMergeCommitThatDependsOnNormalChangeViaTheFirstParent_withRebasingMergeCommits()
-      throws Throwable {
-    /*
          *  change2 (merge, rebased)
          | \
          *  \  change1 (rebased)
@@ -290,66 +226,6 @@
   @Test
   public void submitMergeCommitThatDependsOnNormalChangeViaTheSecondParent() throws Throwable {
     /*
-       *  merge created by Gerrit to integrate change 1 and change 2
-       |\
-       | * change2 (merge)
-       | |\
-       | | * change1
-       | |/
-       * | change3 (new tip)
-       |/
-       * initialHead
-    */
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
-
-    PushOneCommit change2Push =
-        pushFactory.create(admin.newIdent(), testRepo, "Merge to master", "m.txt", "");
-    change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
-    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("New tip", "b.txt", "");
-
-    approve(change3.getChangeId());
-    submit(change3.getChangeId());
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    submit(change2.getChangeId());
-
-    RevCommit newHead = projectOperations.project(project).getHead("master");
-    assertThat(newHead.getParentCount()).isEqualTo(2);
-
-    RevCommit headParent1 = parse(newHead.getParent(0).getId());
-    RevCommit headParent2 = parse(newHead.getParent(1).getId());
-
-    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      assertCurrentRevision(change3.getChangeId(), 2, headParent1.getId());
-    } else {
-      assertThat(change3.getCommit().getId()).isEqualTo(headParent1.getId());
-    }
-    assertThat(headParent1.getParentCount()).isEqualTo(1);
-    assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
-
-    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
-    assertThat(headParent2.getParentCount()).isEqualTo(2);
-
-    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
-    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
-
-    assertThat(headGrandparent1.getId()).isEqualTo(initialHead.getId());
-    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.REBASE_MERGE_COMMITS)
-  public void
-      submitMergeCommitThatDependsOnNormalChangeViaTheSecondParent_withRebasingMergeCommits()
-          throws Throwable {
-    /*
        *  change2 (merge, rebased)
        | \
        *  \  change3 (new tip, rebased if 'Rebase Always')
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index c7672d1..0f245bd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -130,6 +130,11 @@
   @Before
   public void setUp() {
     TimeUtil.setCurrentMillisSupplier(fakeClock);
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
   }
 
   @Test
@@ -1402,10 +1407,51 @@
   }
 
   @Test
-  public void reviewAddsAllUsersInCommentThread() throws Exception {
+  public void ownerReplyResolvedAddsNonVotedInCommentThread() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    change(r).current().review(reviewWithComment());
+    ReviewInput ri = reviewWithComment();
+    ri.label("Code-Review", 2);
+    change(r).current().review(ri);
+
+    TestAccount user2 = accountCreator.user2();
+
+    requestScopeOperations.setApiUser(user2.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    change(r).attention(user.email()).remove(new AttentionSetInput("removal"));
+    requestScopeOperations.setApiUser(admin.id());
+    ri =
+        reviewInReplyToComment(
+            gApi.changes().id(r.getChangeId()).current().commentsAsList().get(1).id);
+    ri.comments.get(Patch.COMMIT_MSG).get(0).unresolved = false;
+    change(r).current().review(ri);
+
+    // First user already voted, no need to bring them back.
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user2));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user2.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+  }
+
+  @Test
+  public void ownerReplyUnresolvedAddsAllUsersInCommentThread() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput ri = reviewWithComment();
+    ri.label("Code-Review", 2);
+    change(r).current().review(ri);
 
     TestAccount user2 = accountCreator.user2();
 
@@ -1443,6 +1489,71 @@
   }
 
   @Test
+  public void reviewerReplyUnresolvedAddsOnlyOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    change(r).current().review(reviewWithComment());
+
+    TestAccount user2 = accountCreator.user2();
+    change(r).attention(admin.email()).remove(new AttentionSetInput("removal"));
+    requestScopeOperations.setApiUser(user2.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    // First user is not needed, owner haven't addressed their comment yet.
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void reviewerReplyResolvedAddsNonVotedInCommentThread() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput ri = reviewWithComment();
+    ri.label("Code-Review", 2);
+    change(r).current().review(ri);
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
+    requestScopeOperations.setApiUser(user3.id());
+    ri =
+        reviewInReplyToComment(
+            gApi.changes().id(r.getChangeId()).current().commentsAsList().get(1).id);
+    ri.comments.get(Patch.COMMIT_MSG).get(0).unresolved = false;
+    change(r).current().review(ri);
+
+    // First user already voted, no need to bring them back.
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user2));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user2.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+  }
+
+  @Test
   public void reviewAddsAllUsersInCommentThreadWhenOriginalCommentIsARobotComment()
       throws Exception {
     PushOneCommit.Result result = createChange();
@@ -3024,8 +3135,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
@@ -3110,8 +3221,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
     assertThat(message.body()).contains("\nPatch Set 2: Code-Review+1\n");
     assertThat(message.body())
@@ -3198,8 +3309,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body()).contains("\nPatch Set 2: Code-Review-2\n");
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
@@ -3278,6 +3389,7 @@
     comment.message = "comment";
     comment.setUpdated(TimeUtil.now());
     comment.inReplyTo = id;
+    comment.unresolved = true;
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     return reviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java
new file mode 100644
index 0000000..c33de03
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class ChangeEditIT extends AbstractDaemonTest {
+  private static final String FILE_NAME = "foo";
+  private static final String FILE_NAME2 = "foo2";
+
+  private static final String FILE_CONTENT = "content";
+  private static final String FILE_CONTENT2 = "content2";
+
+  @Inject private ChangeOperations changeOperations;
+
+  @Test
+  public void modifyMultipleFilesInOneChangeEdit() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    RestResponse response =
+        adminRestSession.putRaw(
+            String.format("/changes/%s/edit/%s", changeId, FILE_NAME),
+            RawInputUtil.create(FILE_CONTENT));
+    assertThat(response.getStatusCode()).isEqualTo(204);
+    RestResponse response2 =
+        adminRestSession.putRaw(
+            String.format("/changes/%s/edit/%s", changeId, FILE_NAME2),
+            RawInputUtil.create(FILE_CONTENT2));
+    assertThat(response2.getStatusCode()).isEqualTo(204);
+    RestResponse publishResponse =
+        adminRestSession.post(String.format("/changes/%s/edit:publish", changeId));
+    assertThat(publishResponse.getStatusCode()).isEqualTo(204);
+    assertThat(gApi.changes().id(changeId.get()).current().files().keySet())
+        .containsExactly("/COMMIT_MSG", FILE_NAME, FILE_NAME2);
+    // Created an initial change, then applied a single edit with two files resulting in one more
+    // patchset.
+    assertThat(gApi.changes().id(changeId.get()).get().currentRevisionNumber).isEqualTo(2);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
index 1094a42..5bb6dc4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
@@ -83,8 +83,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -127,8 +127,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -172,8 +172,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -263,8 +263,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body())
         .contains(
             "The change is no longer submittable:"
@@ -315,8 +315,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -361,8 +361,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -398,8 +398,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -435,8 +435,8 @@
             String.format(
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
-                    + "%s has posted comments on this change.",
-                admin.fullName(), user.fullName(), approver.fullName()));
+                    + "%s has posted comments on this change by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index cf88b5a..d618e2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -61,6 +61,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
@@ -449,7 +450,9 @@
 
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
-    assertThat(m.body()).contains(admin.fullName() + " has posted comments on this change.");
+    assertThat(m.body())
+        .contains(
+            admin.fullName() + " has posted comments on this change by " + admin.fullName() + ".");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
 
@@ -726,11 +729,11 @@
     gApi.changes().id(r.getChangeId()).current().submit();
 
     assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
-    AuthException thrown =
+    ResourceConflictException thrown =
         assertThrows(
-            AuthException.class,
+            ResourceConflictException.class,
             () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
-    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
   }
 
   @Test
@@ -753,11 +756,11 @@
     requestScopeOperations.setApiUser(user.id());
     assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
 
-    AuthException thrown =
+    ResourceConflictException thrown =
         assertThrows(
-            AuthException.class,
+            ResourceConflictException.class,
             () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
-    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
   }
 
   @Test
@@ -835,8 +838,79 @@
   }
 
   @Test
+  public void removeReviewerWithVoteFromMergedChangeFailsWithRemoveReviewerPermission()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
+  public void removeSubmitterFromMergedChangeFailsWithRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(admin.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+  }
+
+  @Test
   @Sandboxed
-  public void removeReviewerWithoutVoteWithPermissionSucceeds() throws Exception {
+  public void removeReviewerWithoutVoteFromOpenChangeWithPermissionSucceeds() throws Exception {
     PushOneCommit.Result r = createChange();
     // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
     // rather than bypassing the check because of project or ref ownership.
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index dcdbce3..04093a5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.converter.ChangeInputProtoConverter;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -82,7 +83,11 @@
 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.patch.ApplyPatchUtil;
+import com.google.gerrit.server.restapi.change.CreateChange;
+import com.google.gerrit.server.restapi.change.CreateChange.CommitTreeSupplier;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
@@ -110,9 +115,13 @@
 
 @UseClockStep
 public class CreateChangeIT extends AbstractDaemonTest {
+  private static final ChangeInputProtoConverter CHANGE_INPUT_PROTO_CONVERTER =
+      ChangeInputProtoConverter.INSTANCE;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private CreateChange createChangeImpl;
+  @Inject private BatchUpdate.Factory updateFactory;
 
   @Before
   public void addNonCommitHead() throws Exception {
@@ -1157,6 +1166,72 @@
   }
 
   @Test
+  public void createChangeWithCommitTreeSupplier_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.branch = "other";
+    input.subject = "custom commit message";
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = PATCH_INPUT;
+    CommitTreeSupplier commitTreeSupplier =
+        (repo, oi, or, in, mergeTip) ->
+            ApplyPatchUtil.applyPatch(repo, oi, applyPatchInput, mergeTip).getTreeId();
+
+    ChangeInfo info = assertCreateWithCommitTreeSupplierSucceeds(input, commitTreeSupplier);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .isEqualTo("custom commit message\n\nChange-Id: " + info.changeId + "\n");
+  }
+
+  @Test
+  public void changePatch_multipleParents_success() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=1");
+    patchResp.assertOK();
+    assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+        .contains("+B content");
+
+    patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=2");
+    patchResp.assertOK();
+    assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+        .contains("+A content");
+  }
+
+  @Test
+  public void changePatch_multipleParents_failure() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + change.id + "/revisions/current/patch");
+    // Maintaining historic logic of failing with 409 Conflict in this case.
+    patchResp.assertConflict();
+  }
+
+  @Test
+  public void changePatch_parent_badRequest() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+    ChangeInfo change = assertCreateSucceeds(in);
+
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=3");
+    // Parent 3 does not exist.
+    patchResp.assertBadRequest();
+
+    patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=0");
+    // Parent 0 does not exist.
+    patchResp.assertBadRequest();
+  }
+
+  @Test
   @UseSystemTime
   public void sha1sOfTwoNewChangesDiffer() throws Exception {
     ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
@@ -1351,6 +1426,18 @@
     return out;
   }
 
+  private ChangeInfo assertCreateWithCommitTreeSupplierSucceeds(
+      ChangeInput input, CommitTreeSupplier commitTreeSupplier) throws Exception {
+    ChangeInfo res =
+        createChangeImpl
+            .execute(updateFactory, CHANGE_INPUT_PROTO_CONVERTER.toProto(input), commitTreeSupplier)
+            .value();
+    // The original result doesn't contain any revision data.
+    ChangeInfo out = gApi.changes().id(res.changeId).get(ALL_REVISIONS, CURRENT_COMMIT);
+    validateCreateSucceeds(input, out);
+    return out;
+  }
+
   private static <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
     try (JsonReader jsonReader = new JsonReader(r.getReader())) {
       return newGson().fromJson(jsonReader, clazz);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
index 26e37f4..ace7b52 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -42,15 +42,15 @@
 
   @Test
   public void metaDiff() throws Exception {
-    PushOneCommit.Result ch = createChange();
-    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
-    chApi.topic(TOPIC);
-    ChangeInfo oldInfo = chApi.get();
-    chApi.topic(TOPIC + "-2");
-    chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
-    ChangeInfo newInfo = chApi.get();
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).topic(TOPIC);
+    ChangeInfo oldInfo = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeId).topic(TOPIC + "-2");
+    gApi.changes().id(changeId).setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+    ChangeInfo newInfo = gApi.changes().id(changeId).get();
 
-    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
+    ChangeInfoDifference difference =
+        gApi.changes().id(changeId).metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
 
     assertThat(difference.added().topic).isEqualTo(newInfo.topic);
     assertThat(difference.added().hashtags).isNotNull();
@@ -161,13 +161,12 @@
 
   @Test
   public void metaDiffNoOldMetaGivenUsesPatchSetBeforeNew() throws Exception {
-    PushOneCommit.Result ch = createChange();
-    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
-    chApi.topic(TOPIC);
-    ChangeInfo newInfo = chApi.get();
-    chApi.topic(TOPIC + "2");
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).topic(TOPIC);
+    ChangeInfo newInfo = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeId).topic(TOPIC + "2");
 
-    ChangeInfoDifference difference = chApi.metaDiff(null, newInfo.metaRevId);
+    ChangeInfoDifference difference = gApi.changes().id(changeId).metaDiff(null, newInfo.metaRevId);
 
     assertThat(difference.added().topic).isEqualTo(TOPIC);
     assertThat(difference.removed().topic).isNull();
@@ -202,17 +201,18 @@
 
   @Test
   public void metaDiffWithOptionIncludesExtraInformation() throws Exception {
-    PushOneCommit.Result ch = createChange();
-    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
-    ChangeInfo oldInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
-    amendChange(ch.getChangeId());
-    ChangeInfo newInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
+    String changeId = createChange().getChangeId();
+    ChangeInfo oldInfo = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
+    amendChange(changeId);
+    ChangeInfo newInfo = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
 
     ChangeInfoDifference difference =
-        chApi.metaDiff(
-            oldInfo.metaRevId,
-            newInfo.metaRevId,
-            ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
+        gApi.changes()
+            .id(changeId)
+            .metaDiff(
+                oldInfo.metaRevId,
+                newInfo.metaRevId,
+                ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
 
     assertThat(newInfo.currentRevision).isNotNull();
     assertThat(oldInfo.currentRevision).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 1d8e0b8..5f1a982 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -95,12 +95,15 @@
     PushOneCommit.Result change2 = createChange();
 
     Change.Id id1 = change1.getPatchSetId().changeId();
+    Change.Id id2 = change2.getPatchSetId().changeId();
     submitWithConflict(
         change2.getChangeId(),
-        "Failed to submit 2 changes due to the following problems:\n"
-            + "Change "
-            + id1
-            + ": submit requirement 'Code-Review' is unsatisfied.");
+        String.format(
+            "Failed to submit 2 changes due to the following problems:\n"
+                + "Change %d"
+                + ": Change %d must be submitted with change %d but %d is not ready: "
+                + "submit requirement 'Code-Review' is unsatisfied.",
+            id1.get(), id2.get(), id1.get(), id1.get()));
 
     RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index a9e3cf6..9ed6d15 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -33,7 +33,7 @@
     TaskInfo info = newGson().fromJson(r.getReader(), new TypeToken<TaskInfo>() {}.getType());
     assertThat(info.id).isNotNull();
     Long.parseLong(info.id, 16);
-    assertThat(info.command).isEqualTo("Log File Compressor");
+    assertThat(info.command).isEqualTo("Log File Manager");
     assertThat(info.startTime).isNotNull();
   }
 
@@ -49,7 +49,7 @@
         newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
     for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
+      if ("Log File Manager".equals(info.command)) {
         return info.id;
       }
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
index a88ee3e..904de9a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
@@ -16,21 +16,20 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.IndexType;
-import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.IndexResource;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexDefinition;
+import com.google.gerrit.server.index.change.ChangeIndexDefinition;
+import com.google.gerrit.server.index.group.GroupIndexDefinition;
+import com.google.gerrit.server.index.project.ProjectIndexDefinition;
 import com.google.gerrit.server.restapi.config.SnapshotIndex;
+import com.google.gerrit.server.restapi.config.SnapshotIndexes;
 import com.google.gerrit.server.restapi.config.SnapshotInfo;
 import com.google.gerrit.testing.SystemPropertiesTestRule;
 import com.google.inject.Inject;
@@ -57,22 +56,25 @@
       new SystemPropertiesTestRule(IndexType.SYS_PROP, "lucene");
 
   @Inject private SnapshotIndex snapshotIndex;
-  @Inject private AccountIndexCollection accountIndexes;
-  @Inject private ChangeIndexCollection changeIndexes;
-  @Inject private GroupIndexCollection groupIndexes;
-  @Inject private ProjectIndexCollection projectIndexes;
+  @Inject private SnapshotIndexes snapshotIndexes;
+  @Inject private AccountIndexDefinition accountIndexDefinition;
+  @Inject private ChangeIndexDefinition changeIndexDefinition;
+  @Inject private GroupIndexDefinition groupIndexDefinition;
+  @Inject private ProjectIndexDefinition projectIndexDefinition;
 
   @Inject private SitePaths sitePaths;
-  @Inject private Collection<IndexDefinition<?, ?, ?>> defs;
+
+  @Test
+  @UseLocalDisk
+  public void createAccountsIndexSnapshot() throws Exception {
+    Query query = new TermQuery(new Term("is", "active"));
+    createAndVerifySnapshot(new IndexResource(accountIndexDefinition), "accounts", query);
+  }
 
   @Test
   @UseLocalDisk
   public void createFullSnapshot() throws Exception {
-    ImmutableList.Builder<IndexCollection<?, ?, ?>> indexCollections = ImmutableList.builder();
-    for (IndexDefinition<?, ?, ?> def : defs) {
-      indexCollections.add(def.getIndexCollection());
-    }
-    File snapshot = createSnapshot(new IndexResource(indexCollections.build()));
+    File snapshot = createSnapshotOfAllIndexes();
     File[] members = snapshot.listFiles();
     for (File member : members) {
       assertThat(member.isDirectory()).isTrue();
@@ -82,30 +84,23 @@
 
   @Test
   @UseLocalDisk
-  public void createAccountsIndexSnapshot() throws Exception {
-    Query query = new TermQuery(new Term("is", "active"));
-    createAndVerifySnapshot(new IndexResource(accountIndexes, null), "accounts", query);
-  }
-
-  @Test
-  @UseLocalDisk
   public void createChangesIndexSnapshot() throws Exception {
     Query query = new TermQuery(new Term("status", "open"));
-    createAndVerifySnapshot(new IndexResource(changeIndexes, null), "changes", query);
+    createAndVerifySnapshot(new IndexResource(changeIndexDefinition), "changes", query);
   }
 
   @Test
   @UseLocalDisk
   public void createGroupsIndexSnapshot() throws Exception {
     Query query = new TermQuery(new Term("is", "active"));
-    createAndVerifySnapshot(new IndexResource(groupIndexes, null), "groups", query);
+    createAndVerifySnapshot(new IndexResource(groupIndexDefinition), "groups", query);
   }
 
   @Test
   @UseLocalDisk
   public void createProjectsIndexSnapshot() throws Exception {
     Query query = new TermQuery(new Term("name", "foo"));
-    createAndVerifySnapshot(new IndexResource(projectIndexes, null), "projects", query);
+    createAndVerifySnapshot(new IndexResource(projectIndexDefinition), "projects", query);
   }
 
   private File createAndVerifySnapshot(IndexResource rsrc, String prefix, Query query)
@@ -113,8 +108,10 @@
     File snapshot = createSnapshot(rsrc);
 
     File[] subdirs = snapshot.listFiles();
-    assertThat(subdirs).hasLength(rsrc.getIndexes().size());
-    for (Index<?, ?> i : rsrc.getIndexes()) {
+    Collection<? extends Index<?, ?>> indexes =
+        rsrc.getIndexDefinition().getIndexCollection().getWriteIndexes();
+    assertThat(subdirs).hasLength(indexes.size());
+    for (Index<?, ?> i : indexes) {
       String indexDirName = String.format("%s_%04d", prefix, i.getSchema().getVersion());
       File[] result = snapshot.listFiles((d, n) -> n.equals(indexDirName));
       assertThat(result).hasLength(1);
@@ -126,6 +123,15 @@
 
   private File createSnapshot(IndexResource rsrc) throws IOException {
     Response<?> rsp = snapshotIndex.apply(rsrc, new SnapshotIndex.Input());
+    return verifySnapshot(rsp);
+  }
+
+  private File createSnapshotOfAllIndexes() throws IOException {
+    Response<?> rsp = snapshotIndexes.apply(new ConfigResource(), new SnapshotIndexes.Input());
+    return verifySnapshot(rsp);
+  }
+
+  private File verifySnapshot(Response<?> rsp) {
     assertThat(rsp.value()).isInstanceOf(SnapshotInfo.class);
     SnapshotInfo snapshotInfo = (SnapshotInfo) rsp.value();
     Path snapshotDir = sitePaths.index_dir.resolve("snapshots").resolve(snapshotInfo.id);
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index 2aa350e..ab3689b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -36,7 +36,7 @@
 
     Optional<String> id =
         result.stream()
-            .filter(t -> "Log File Compressor".equals(t.command))
+            .filter(t -> "Log File Manager".equals(t.command))
             .map(t -> t.id)
             .findFirst();
     assertThat(id).isPresent();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
index 674ca79..cad0875 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -34,7 +34,7 @@
     assertThat(result).isNotEmpty();
     boolean foundLogFileCompressorTask = false;
     for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
+      if ("Log File Manager".equals(info.command)) {
         foundLogFileCompressorTask = true;
       }
       assertThat(info.id).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index b8b63e6..479baf3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -18,21 +18,29 @@
 import static com.google.gerrit.server.config.GerritInstanceIdProvider.INSTANCE_ID_SYSTEM_PROPERTY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.config.GerritSystemProperty;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.common.MetadataInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.server.ServerStateProvider;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.inject.Inject;
+import java.util.ArrayList;
 import org.junit.Test;
 
 @NoHttpd
@@ -41,6 +49,8 @@
   private static final byte[] JS_PLUGIN_CONTENT =
       "Gerrit.install(function(self){});\n".getBytes(UTF_8);
 
+  @Inject protected ExtensionRegistry extensionRegistry;
+
   @Test
   // accounts
   @GerritConfig(name = "accounts.visibility", value = "VISIBLE_GROUP")
@@ -234,4 +244,44 @@
     ServerInfo i = gApi.config().server().getInfo();
     assertThat(i.gerrit.instanceId).isEqualTo("sysPropInstanceId");
   }
+
+  @Test
+  public void getMetadata() throws Exception {
+    TestServerStateProvider testServerStateProvider = new TestServerStateProvider();
+    MetadataInfo metadata1 = testServerStateProvider.addMetadata("bugComponent", "123456", null);
+    MetadataInfo metadata2 =
+        testServerStateProvider.addMetadata("email", null, "email to contact the host owners");
+    MetadataInfo metadata3 =
+        testServerStateProvider.addMetadata("ownerGroup", null, "group that owns the host");
+    MetadataInfo metadata4 =
+        testServerStateProvider.addMetadata("ownerGroup", "Bar", "group that owns the host");
+    MetadataInfo metadata5 =
+        testServerStateProvider.addMetadata("ownerGroup", "Foo", "group that owns the host");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testServerStateProvider)) {
+      ServerInfo serverInfo = gApi.config().server().getInfo();
+      assertThat(serverInfo.metadata)
+          .containsExactly(metadata1, metadata2, metadata3, metadata4, metadata5)
+          .inOrder();
+    }
+  }
+
+  public static class TestServerStateProvider implements ServerStateProvider {
+    private ArrayList<MetadataInfo> metadataList = new ArrayList<>();
+
+    public MetadataInfo addMetadata(
+        String name, @Nullable String value, @Nullable String description) {
+      MetadataInfo metadata = new MetadataInfo();
+      metadata.name = name;
+      metadata.value = value;
+      metadata.description = description;
+      metadataList.add(metadata);
+      return metadata;
+    }
+
+    @Override
+    public ImmutableList<MetadataInfo> getMetadata() {
+      return ImmutableList.copyOf(metadataList);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 4453345..00d8f4e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.schema.SchemaCreatorImpl;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import org.junit.Test;
@@ -34,6 +35,7 @@
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
     assertThat(groupMap.keySet())
-        .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
+        .containsExactly(
+            "Administrators", SchemaCreatorImpl.BLOCKED_USERS, ServiceUserClassifier.SERVICE_USERS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 462c76f..6b61252 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.inject.Inject;
 import org.junit.Test;
@@ -512,4 +514,18 @@
     assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Add Foo Label");
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_createLabelRejected() {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.description = "Foo label description";
+
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index 6a8c9d8..4597819 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -21,12 +21,14 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
 import org.junit.Test;
@@ -123,4 +125,14 @@
     @SuppressWarnings("unused")
     var unused = gApi.changes().id(changeId).get(DETAILED_LABELS);
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_deleteLabelRejected() {
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
new file mode 100644
index 0000000..adc0a8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
@@ -0,0 +1,386 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for the {@link PostLabels} REST endpoint. */
+public class PostSubmitRequirementsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .submitRequirements(new BatchSubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .submitRequirements(new BatchSubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void deleteNonExistingSR() throws Exception {
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void deleteSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo", "Bar");
+    gApi.projects().name(project.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+  }
+
+  @Test
+  public void deleteSR_namesAreTrimmed() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of(" Foo ", " Bar ");
+    gApi.projects().name(project.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+  }
+
+  @Test
+  public void cannotDeleteTheSameSRTwice() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo", "Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void cannotCreateSRWithNameThatIsAlreadyInUse() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    SubmitRequirementInput srInput = new SubmitRequirementInput();
+    srInput.name = "Foo";
+    srInput.allowOverrideInChildProjects = false;
+    srInput.submittabilityExpression = "label:code-review=+2";
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(srInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void cannotCreateTwoSRWithTheSameName() throws Exception {
+    SubmitRequirementInput srInput = new SubmitRequirementInput();
+    srInput.name = "Foo";
+    srInput.allowOverrideInChildProjects = false;
+    srInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(srInput, srInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void cannotCreateTwoSrWithConflictingNames() throws Exception {
+    SubmitRequirementInput sr1Input = new SubmitRequirementInput();
+    sr1Input.name = "Foo";
+    sr1Input.allowOverrideInChildProjects = false;
+    sr1Input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInput sr2Input = new SubmitRequirementInput();
+    sr2Input.name = "foo";
+    sr2Input.allowOverrideInChildProjects = false;
+    sr2Input.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(sr1Input, sr2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement \"foo\" conflicts with existing submit requirement \"Foo\"");
+  }
+
+  @Test
+  public void createSubmitRequirements() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInput barInput = new SubmitRequirementInput();
+    barInput.name = "Bar";
+    barInput.allowOverrideInChildProjects = false;
+    barInput.submittabilityExpression = "label:code-review=+1";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void cannotCreateSRWithIncorrectName() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo ";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(fooInput, fooInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Name can only consist of alphanumeric characters");
+  }
+
+  @Test
+  public void cannotCreateSRWithoutName() throws Exception {
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.create = ImmutableList.of(new SubmitRequirementInput());
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Empty submit requirement name");
+  }
+
+  @Test
+  public void updateNonExistingSR() throws Exception {
+    SubmitRequirementInput fooInput = new SubmitRequirementInput();
+    fooInput.name = "Foo2";
+    fooInput.allowOverrideInChildProjects = false;
+    fooInput.submittabilityExpression = "label:code-review=+2";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.update = ImmutableMap.of("Foo", fooInput);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void updateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+    configSubmitRequirement(project, "Bar");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    SubmitRequirementInput barUpdate = new SubmitRequirementInput();
+    barUpdate.name = "Baz";
+    barUpdate.submittabilityExpression = "label:code-review=+1";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.update = ImmutableMap.of("Foo", fooUpdate, "Bar", barUpdate);
+
+    gApi.projects().name(project.get()).submitRequirements(input);
+
+    assertThat(gApi.projects().name(project.get()).submitRequirement("Foo").get().description)
+        .isEqualTo(fooUpdate.description);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .submitRequirement("Foo")
+                .get()
+                .submittabilityExpression)
+        .isEqualTo(fooUpdate.submittabilityExpression);
+    assertThat(gApi.projects().name(project.get()).submitRequirement("Baz").get()).isNotNull();
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .submitRequirement("Baz")
+                .get()
+                .submittabilityExpression)
+        .isEqualTo(barUpdate.submittabilityExpression);
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).submitRequirement("Bar").get());
+  }
+
+  @Test
+  public void deleteAndRecreateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooUpdate);
+
+    gApi.projects().name(project.get()).submitRequirements(input);
+
+    SubmitRequirementInfo fooSR =
+        gApi.projects().name(project.get()).submitRequirement("Foo").get();
+    assertThat(fooSR.description).isEqualTo(fooUpdate.description);
+    assertThat(fooSR.submittabilityExpression).isEqualTo(fooUpdate.submittabilityExpression);
+  }
+
+  @Test
+  public void cannotDeleteAndUpdateSR() throws Exception {
+    configSubmitRequirement(project, "Foo");
+
+    SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+    fooUpdate.name = "Foo";
+    fooUpdate.description = "new description";
+    fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    input.update = ImmutableMap.of("Foo", fooUpdate);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements(input));
+    assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    gApi.projects().name(allProjects.get()).submitRequirements(new BatchSubmitRequirementInput());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update Submit Requirements");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.commitMessage = "Batch Update SubmitRequirements";
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    configSubmitRequirement(allProjects, "Foo");
+    BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+    input.commitMessage = "Batch Update SubmitRequirements ";
+    input.delete = ImmutableList.of("Foo");
+    gApi.projects().name(allProjects.get()).submitRequirements(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Batch Update SubmitRequirements");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b4731db..4760eb5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
@@ -671,4 +673,16 @@
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Set NoOp function");
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+  public void requireChangeForConfigUpdate_setLabelRejected() {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    MethodNotAllowedException e =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
+    assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index cd687f3..e2809b0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -23,9 +23,11 @@
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccessSection;
@@ -34,6 +36,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -119,6 +122,7 @@
   }
 
   @Test
+  @UseClockStep
   public void listTags() throws Exception {
     createTags();
 
@@ -155,6 +159,23 @@
 
     // With conflicting options
     assertBadRequest(getTags().withSubstring("ag-B").withRegex("^tag-[c|d]$"));
+
+    // with descending order
+    result = getTags().withDescendingOrder(true).get();
+    assertTagList(FluentIterable.from(Lists.reverse(testTags)), result);
+
+    // with sortBy creation time
+    result = getTags().withSortBy(ListTagSortOption.CREATION_TIME).get();
+    assertTagList(FluentIterable.from(Lists.reverse(testTags)), result);
+
+    // with sortBy, descending order and limit
+    result =
+        getTags()
+            .withDescendingOrder(true)
+            .withLimit(2)
+            .withSortBy(ListTagSortOption.CREATION_TIME)
+            .get();
+    assertTagList(FluentIterable.from(ImmutableList.of("tag-A", "tag-B")), result);
   }
 
   @Test
@@ -476,9 +497,10 @@
     TagInput input = new TagInput();
     input.revision = revision;
 
-    for (String tagname : testTags) {
+    // Creating the tags in reverse order to allow testing the sortBy option
+    for (String tagname : Lists.reverse(testTags)) {
+      input.message = tagname; // This updates the 'created' time of the tag
       TagInfo result = tag(tagname).create(input).get();
-      assertThat(result.revision).isEqualTo(input.revision);
       assertThat(result.ref).isEqualTo(R_TAGS + tagname);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java
new file mode 100644
index 0000000..3d762f8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.junit.Test;
+
+public class AccountLimitsIT extends AbstractDaemonTest {
+
+  @Inject private AccountLimits.Factory accountLimitsFactory;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Provider<CurrentUser> currentUserProvider;
+
+  @Test
+  public void shouldIgnoreQueryLimitForInternalUser() throws Exception {
+    requestScopeOperations.setApiUserInternal();
+    AccountLimits objectUnderTest = accountLimitsFactory.create(currentUserProvider.get());
+
+    assertThat(objectUnderTest.getQueryLimit()).isEqualTo(Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void shouldDefaultValueForReqularUser() {
+    AccountLimits objectUnderTest = accountLimitsFactory.create(currentUserProvider.get());
+
+    assertThat(objectUnderTest.getQueryLimit()).isEqualTo(GlobalCapability.DEFAULT_MAX_QUERY_LIMIT);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index 2476f00..a696354 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -433,7 +433,7 @@
     Optional<AccountState> result =
         accountsUpdateProvider
             .get()
-            .update("Force set preferred email", id, (s, u) -> u.setPreferredEmail(email));
+            .update("Force set preferred email", id, u -> u.setPreferredEmail(email));
     assertThat(result.map(a -> a.account().preferredEmail())).hasValue(email);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
index 35082ec..6bfd988 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -33,7 +33,7 @@
 import com.google.common.truth.StandardSubjectBuilder;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth8;
+import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -489,7 +489,7 @@
                       approvalData.patchSetApproval().label().equals(labelId)
                           && approvalData.patchSetApproval().accountId().equals(accountId))
               .findAny();
-      Truth8.assertThat(approvalDataForLabelAndAccount).isPresent();
+      Truth.assertThat(approvalDataForLabelAndAccount).isPresent();
       return assertAbout(approvalDatas()).that(approvalDataForLabelAndAccount.get());
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index a6cdfa1..eee43bd 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.acceptance.server.change;
 
+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.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
-import static com.google.gerrit.acceptance.server.change.CommentsUtil.createRange;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -757,7 +757,7 @@
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.KEEP;
     reviewInput.message = "foo";
-    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "comment", false);
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "text", true);
     // Replace the existing draft.
     comment.id = draftInfo.id;
     reviewInput.comments = new HashMap<>();
@@ -767,6 +767,15 @@
     // DraftHandling.KEEP is ignored on publishing a comment.
     drafts = getDraftComments(changeId, revId);
     assertThat(drafts).isEmpty();
+
+    // Verify the comment
+    ImmutableList<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().get().values().stream()
+            .flatMap(l -> l.stream())
+            .collect(toImmutableList());
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).message).isEqualTo("text");
+    assertThat(comments.get(0).unresolved).isEqualTo(true);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 7eec5ea..93d6422 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.change.GetRelatedChangesUtil;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -86,7 +85,6 @@
   @Inject private IndexOperations.Change changeIndexOperations;
 
   @Inject private IndexConfig indexConfig;
-  @Inject private ChangesCollection changes;
 
   @Test
   public void getRelatedNoResult() throws Exception {
@@ -146,15 +144,11 @@
     testRepo.reset(c1_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
-    String oldETag = changes.parse(ps1_1.changeId()).getETag();
 
     testRepo.reset(c2_1);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
-    // Push of change 2 should not affect groups (or anything else) of change 1.
-    assertThat(changes.parse(ps1_1.changeId()).getETag()).isEqualTo(oldETag);
-
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
       assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 5a4f073..6fabd1a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -16,22 +16,35 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AccountGroup;
+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.SubmitInput;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.EnumSet;
@@ -336,6 +349,174 @@
     assertSubmittedTogether(id2, id2, id1);
   }
 
+  @Test
+  public void permissionToSubmitForSomeChangesInTopic() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.SUBMIT).ref("refs/heads/testbranch").group(REGISTERED_USERS))
+        .update();
+
+    createBranch(BranchNameKey.create(getProject(), "testbranch"));
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    // Create two independent commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/testbranch%topic=" + name("connectingTopic"), false);
+
+    approve(id1);
+    approve(id2);
+    if (isSubmitWholeTopicEnabled()) {
+      ResourceConflictException e =
+          assertThrows(ResourceConflictException.class, () -> submit(id1));
+      assertThat(e.getMessage())
+          .contains(
+              String.format(
+                  "Insufficient permission to submit change %d",
+                  gApi.changes().id(id2).get()._number));
+    } else {
+      submit(id1);
+    }
+  }
+
+  @Test
+  public void permissionToSubmitAsForSomeChangesInTopic() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT_AS).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(block(Permission.SUBMIT_AS).ref("refs/heads/testbranch").group(REGISTERED_USERS))
+        .update();
+
+    createBranch(BranchNameKey.create(getProject(), "testbranch"));
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    // Create two independent commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/testbranch%topic=" + name("connectingTopic"), false);
+
+    approve(id1);
+    approve(id2);
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = accountCreator.user2().email();
+    if (isSubmitWholeTopicEnabled()) {
+      ResourceConflictException e =
+          assertThrows(
+              ResourceConflictException.class, () -> gApi.changes().id(id1).current().submit(in));
+      assertThat(e.getMessage())
+          .contains(
+              String.format(
+                  "Insufficient permission to submit change %d on behalf of user %s",
+                  gApi.changes().id(id2).get()._number, accountCreator.user2().username()));
+    } else {
+      gApi.changes().id(id1).current().submit(in);
+      assertMerged(id1);
+      assertNotMerged(id2);
+    }
+  }
+
+  @Test
+  public void submitAs_onBehalfOfUserNoReadPermissionToSomeChanges() throws Exception {
+    GroupInfo newGroup = createGroupForUser(accountCreator.user2());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.READ)
+                .ref("refs/heads/testbranch")
+                .group(AccountGroup.uuid(newGroup.id)))
+        .add(allow(Permission.SUBMIT_AS).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    createBranch(BranchNameKey.create(getProject(), "testbranch"));
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    // Create two independent commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/testbranch%topic=" + name("connectingTopic"), false);
+
+    approve(id1);
+    approve(id2);
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = accountCreator.user2().email();
+    if (isSubmitWholeTopicEnabled()) {
+      ResourceConflictException e =
+          assertThrows(
+              ResourceConflictException.class, () -> gApi.changes().id(id1).current().submit(in));
+      assertThat(e.getMessage())
+          .contains(
+              String.format(
+                  "On-behalf-of user %s lacks permission to read change %d",
+                  accountCreator.user2().username(), gApi.changes().id(id2).get()._number));
+    } else {
+      gApi.changes().id(id1).current().submit(in);
+      assertMerged(id1);
+      assertNotMerged(id2);
+    }
+  }
+
+  @Test
+  public void submitAs_succeeds() throws Exception {
+    GroupInfo newGroup = createGroupForUser(accountCreator.user2());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            block(Permission.SUBMIT)
+                .ref("refs/heads/testbranch")
+                .group(AccountGroup.uuid(newGroup.id)))
+        .add(allow(Permission.SUBMIT_AS).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    createBranch(BranchNameKey.create(getProject(), "testbranch"));
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    // Create two independent commits and push.
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    String id1 = getChangeId(c1_1);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
+
+    testRepo.reset(initialHead);
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/testbranch%topic=" + name("connectingTopic"), false);
+
+    approve(id1);
+    approve(id2);
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = accountCreator.user2().email();
+    if (isSubmitWholeTopicEnabled()) {
+      gApi.changes().id(id1).current().submit(in);
+      assertMerged(id1);
+      assertMerged(id2);
+    } else {
+      gApi.changes().id(id1).current().submit(in);
+      assertMerged(id1);
+      assertNotMerged(id2);
+    }
+  }
+
+  private GroupInfo createGroupForUser(TestAccount user) throws Exception {
+    GroupInput gi = new GroupInput();
+    gi.name = name("New-Group");
+    gi.members = ImmutableList.of(user.id().toString());
+    return gApi.groups().create(gi).get();
+  }
+
   private String getChangeId(RevCommit c) throws Exception {
     return GitUtil.getChangeId(testRepo, c).get();
   }
@@ -347,4 +528,8 @@
   private void assertMerged(String changeId) throws Exception {
     assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
+
+  private void assertNotMerged(String changeId) throws Exception {
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.NEW);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/index/BUILD b/javatests/com/google/gerrit/acceptance/server/index/BUILD
new file mode 100644
index 0000000..1d4ef02
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["**/*IT.java"]),
+    group = "server_index",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/index/ReindexIndexVersionIT.java b/javatests/com/google/gerrit/acceptance/server/index/ReindexIndexVersionIT.java
new file mode 100644
index 0000000..d433ca7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/ReindexIndexVersionIT.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.index;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.restapi.config.ReindexIndexVersion;
+import com.google.inject.Inject;
+import java.util.Collection;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ReindexIndexVersionIT extends AbstractDaemonTest {
+
+  @Inject private ReindexIndexVersion reindexIndexVersion;
+  @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  private IndexDefinition<?, ?, ?> def;
+  private Index<?, ?> changeIndex;
+  private Change.Id C1;
+  private Change.Id C2;
+
+  private ChangeIndexedListener changeIndexedListener;
+  private ReindexIndexVersion.Input input = new ReindexIndexVersion.Input();
+
+  @Before
+  public void setUp() throws Exception {
+    def = indexDefs.stream().filter(i -> i.getName().equals("changes")).findFirst().get();
+    changeIndex = def.getIndexCollection().getSearchIndex();
+    C1 = createChange().getChange().getId();
+    C2 = createChange().getChange().getId();
+    changeIndexedListener = mock(ChangeIndexedListener.class);
+    input = new ReindexIndexVersion.Input();
+  }
+
+  @Test
+  public void reindexWithListenerNotification() throws Exception {
+    input.notifyListeners = true;
+    reindex();
+    verify(changeIndexedListener, times(1)).onChangeIndexed(project.get(), C1.get());
+    verify(changeIndexedListener, times(1)).onChangeIndexed(project.get(), C2.get());
+  }
+
+  @Test
+  public void reindexWithoutListenerNotification() throws Exception {
+    input.notifyListeners = false;
+    reindex();
+    verifyNoInteractions(changeIndexedListener);
+  }
+
+  private void reindex() throws ResourceNotFoundException {
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedListener)) {
+      Response<?> rsp =
+          reindexIndexVersion.apply(new IndexVersionResource(def, changeIndex), input);
+      assertThat(rsp.statusCode()).isEqualTo(HttpServletResponse.SC_ACCEPTED);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java b/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java
new file mode 100644
index 0000000..b8af367
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.SiteIndexer.Result;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+public class LuceneChangeIndexerIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+    cfg.setString("index", null, "type", "lucene");
+    return cfg;
+  }
+
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
+  private AllChangesIndexer allChangesIndexer;
+  private ChangeIndex index;
+
+  @Before
+  public void setup() {
+    IndexDefinition<?, ?, ?> changeIndex =
+        indexDefs.stream().filter(i -> i.getName().equals("changes")).findFirst().get();
+    allChangesIndexer = (AllChangesIndexer) changeIndex.getSiteIndexer();
+    index = (ChangeIndex) changeIndex.getIndexCollection().getWriteIndexes().iterator().next();
+  }
+
+  @Test
+  @GerritConfig(name = "index.reuseExistingDocuments", value = "false")
+  public void testReindexWithoutReuse() throws Exception {
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      createChange();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+      changeIndexedCounter.clear();
+      reindexChanges();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+
+      createIndexWithMissingChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(2);
+
+      createIndexWithStaleChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(3);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "index.reuseExistingDocuments", value = "true")
+  public void testReindexWithReuse() throws Exception {
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      createChange();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+      changeIndexedCounter.clear();
+      reindexChanges();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(0);
+
+      createIndexWithMissingChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+
+      createIndexWithStaleChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+    }
+  }
+
+  private void createIndexWithMissingChangeAndReindex(ChangeIndexedCounter changeIndexedCounter)
+      throws Exception {
+    PushOneCommit.Result res = createChange();
+    index.delete(res.getChange().getId());
+    changeIndexedCounter.clear();
+    reindexChanges();
+  }
+
+  private void createIndexWithStaleChangeAndReindex(ChangeIndexedCounter changeIndexedCounter)
+      throws Exception {
+    PushOneCommit.Result res = createChange();
+    ChangeData wrongChangeData = res.getChange();
+    ListMultimap<NameKey, RefState> refStates =
+        LinkedListMultimap.create(wrongChangeData.getRefStates());
+    refStates.replaceValues(
+        project,
+        Set.of(
+            RefState.create(
+                "refs/changes/abcd",
+                ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))));
+    wrongChangeData.setRefStates(ImmutableSetMultimap.copyOf(refStates));
+    index.replace(wrongChangeData);
+    changeIndexedCounter.clear();
+    reindexChanges();
+  }
+
+  private void reindexChanges() throws Exception {
+    Result res = allChangesIndexer.indexAll(index);
+    assertThat(res.success()).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/index/scheduler/PeriodicIndexerConfigProviderIT.java b/javatests/com/google/gerrit/acceptance/server/index/scheduler/PeriodicIndexerConfigProviderIT.java
new file mode 100644
index 0000000..20676c5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/scheduler/PeriodicIndexerConfigProviderIT.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.index.scheduler;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.gerrit.server.index.scheduler.PeriodicIndexerConfig;
+import com.google.gerrit.server.index.scheduler.PeriodicIndexerConfigProvider;
+import com.google.inject.Inject;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+
+public class PeriodicIndexerConfigProviderIT extends AbstractDaemonTest {
+
+  @Inject private PeriodicIndexerConfigProvider indexConfigProvider;
+
+  @Test
+  public void emptyConfigOnPrimary_noIndexersWillRun() {
+    Map<String, PeriodicIndexerConfig> indexConfig = indexConfigProvider.get();
+    assertThat(indexConfig).isEmpty();
+  }
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "container.replica", value = "true")
+  public void emptyConfigOnReplica_groupsIndexerWillRun() throws Exception {
+    Map<String, PeriodicIndexerConfig> indexConfig = indexConfigProvider.get();
+    assertThat(indexConfig).hasSize(1);
+    assertThat(indexConfig).containsKey("groups");
+
+    PeriodicIndexerConfig groupsIndexerConfig = indexConfig.get("groups");
+    assertThat(groupsIndexerConfig.runOnStartup()).isTrue();
+    assertThat(groupsIndexerConfig.enabled()).isTrue();
+    assertThat(groupsIndexerConfig.schedule())
+        .isEqualTo(PeriodicIndexerConfigProvider.DEFAULT_SCHEDULE);
+  }
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "scheduledIndexer.accounts.runOnStartup", value = "false")
+  @GerritConfig(name = "scheduledIndexer.accounts.enabled", value = "true")
+  @GerritConfig(name = "scheduledIndexer.accounts.startTime", value = "01:00")
+  @GerritConfig(name = "scheduledIndexer.accounts.interval", value = "2h")
+  public void parseScheduledIndexerConfig() {
+    Map<String, PeriodicIndexerConfig> indexConfig = indexConfigProvider.get();
+    assertThat(indexConfig).hasSize(1);
+    assertThat(indexConfig).containsKey("accounts");
+
+    PeriodicIndexerConfig accountsIndexerConfig = indexConfig.get("accounts");
+    assertThat(accountsIndexerConfig.runOnStartup()).isFalse();
+    assertThat(accountsIndexerConfig.enabled()).isTrue();
+
+    Schedule schedule = accountsIndexerConfig.schedule();
+    assertThat(schedule.interval()).isEqualTo(TimeUnit.HOURS.toMillis(2));
+    // NOTE: it is impossible to assert the equality of the two Schedule instance
+    // because every instance computes initialDelay based on the current time.
+    // Therefore, we only assert the equality of the interval here.
+  }
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "index.scheduledIndexer.runOnStartup", value = "true")
+  @GerritConfig(name = "index.scheduledIndexer.enabled", value = "false")
+  @GerritConfig(name = "index.scheduledIndexer.startTime", value = "01:00")
+  @GerritConfig(name = "index.scheduledIndexer.interval", value = "3h")
+  public void indexSectionStillSupportedForGroups() {
+    Map<String, PeriodicIndexerConfig> indexConfig = indexConfigProvider.get();
+    assertThat(indexConfig).hasSize(1);
+    assertThat(indexConfig).containsKey("groups");
+
+    PeriodicIndexerConfig groupsIndexerConfig = indexConfig.get("groups");
+    assertThat(groupsIndexerConfig.runOnStartup()).isTrue();
+    assertThat(groupsIndexerConfig.enabled()).isFalse();
+
+    Schedule schedule = groupsIndexerConfig.schedule();
+    assertThat(schedule.interval()).isEqualTo(TimeUnit.HOURS.toMillis(3));
+  }
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "scheduledIndexer.groups.runOnStartup", value = "false")
+  @GerritConfig(name = "scheduledIndexer.groups.enabled", value = "true")
+  @GerritConfig(name = "scheduledIndexer.groups.startTime", value = "01:00")
+  @GerritConfig(name = "scheduledIndexer.groups.interval", value = "2h")
+  @GerritConfig(name = "index.scheduledIndexer.runOnStartup", value = "true")
+  @GerritConfig(name = "index.scheduledIndexer.enabled", value = "false")
+  @GerritConfig(name = "index.scheduledIndexer.startTime", value = "01:00")
+  @GerritConfig(name = "index.scheduledIndexer.interval", value = "3h")
+  public void scheduledIndexerSectionOverridesIndexSection() {
+    Map<String, PeriodicIndexerConfig> indexConfig = indexConfigProvider.get();
+    assertThat(indexConfig).hasSize(1);
+    assertThat(indexConfig).containsKey("groups");
+
+    PeriodicIndexerConfig groupsIndexerConfig = indexConfig.get("groups");
+    assertThat(groupsIndexerConfig.runOnStartup()).isFalse();
+    assertThat(groupsIndexerConfig.enabled()).isTrue();
+
+    Schedule schedule = groupsIndexerConfig.schedule();
+    assertThat(schedule.interval()).isEqualTo(TimeUnit.HOURS.toMillis(2));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 2ee5360..d88db52 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -2369,6 +2369,12 @@
 
   @Test
   public void revertChangeByOwner() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, sc.owner);
 
@@ -2394,6 +2400,12 @@
 
   @Test
   public void revertChangeByOwnerCcingSelf() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
 
@@ -2420,6 +2432,12 @@
 
   @Test
   public void revertChangeByOther() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, other);
 
@@ -2446,6 +2464,12 @@
 
   @Test
   public void revertChangeByOtherCcingSelf() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
     StagedChange sc = stageChange();
     revert(sc, other, CC_ON_OWN_COMMENTS);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 576c7d0..2a210af 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -20,8 +20,6 @@
 import static com.google.gerrit.entities.LabelFunction.ANY_WITH_BLOCK;
 import static com.google.gerrit.entities.LabelFunction.MAX_NO_BLOCK;
 import static com.google.gerrit.entities.LabelFunction.MAX_WITH_BLOCK;
-import static com.google.gerrit.entities.LabelFunction.NO_BLOCK;
-import static com.google.gerrit.entities.LabelFunction.NO_OP;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
@@ -78,7 +76,7 @@
 
   @Test
   public void customLabelNoOp_NegativeVoteNotBlock() throws Exception {
-    saveLabelConfig(LABEL.toBuilder().setFunction(NO_OP));
+    saveLabelConfig(LABEL.toBuilder().setNoBlockFunction());
     PushOneCommit.Result r = createChange();
     revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
@@ -93,7 +91,7 @@
 
   @Test
   public void customLabelNoBlock_NegativeVoteNotBlock() throws Exception {
-    saveLabelConfig(LABEL.toBuilder().setFunction(NO_BLOCK));
+    saveLabelConfig(LABEL.toBuilder().setNoBlockFunction());
     PushOneCommit.Result r = createChange();
     revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
@@ -106,6 +104,7 @@
     assertThat(q.blocking).isNull();
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelMaxNoBlock_NegativeVoteNotBlock() throws Exception {
     saveLabelConfig(LABEL.toBuilder().setFunction(MAX_NO_BLOCK));
@@ -121,9 +120,11 @@
     assertThat(q.blocking).isNull();
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelMaxNoBlock_MaxVoteSubmittable() throws Exception {
-    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_NO_BLOCK), P.toBuilder().setFunction(NO_OP));
+    saveLabelConfig(
+        LABEL.toBuilder().setFunction(MAX_NO_BLOCK), P.toBuilder().setNoBlockFunction());
     PushOneCommit.Result r = createChange();
     assertThat(info(r.getChangeId()).submittable).isNull();
     revision(r).review(ReviewInput.approve().label(LABEL_NAME, 1));
@@ -139,6 +140,7 @@
     assertThat(q.blocking).isNull();
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelAnyWithBlock_NegativeVoteBlock() throws Exception {
     saveLabelConfig(LABEL.toBuilder().setFunction(ANY_WITH_BLOCK));
@@ -163,6 +165,7 @@
     }
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelAnyWithBlock_Addreviewer_ZeroVote() throws Exception {
     TestListener testListener = new TestListener();
@@ -190,6 +193,7 @@
     }
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelMaxWithBlock_NegativeVoteBlock() throws Exception {
     saveLabelConfig(LABEL.toBuilder().setFunction(MAX_WITH_BLOCK));
@@ -205,6 +209,7 @@
     assertThat(q.blocking).isTrue();
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelMaxWithBlock_DeletedVoteDoesNotTriggerNegativeBlock() throws Exception {
     saveLabelConfig(P.toBuilder().setFunction(MAX_WITH_BLOCK));
@@ -227,10 +232,11 @@
     assertThat(labelInfo.blocking).isNull(); // label is not blocking the change submission
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelMaxWithBlock_MaxVoteSubmittable() throws Exception {
     saveLabelConfig(
-        LABEL.toBuilder().setFunction(MAX_WITH_BLOCK), P.toBuilder().setFunction(NO_OP));
+        LABEL.toBuilder().setFunction(MAX_WITH_BLOCK), P.toBuilder().setNoBlockFunction());
     PushOneCommit.Result r = createChange();
     assertThat(info(r.getChangeId()).submittable).isNull();
     revision(r).review(ReviewInput.approve().label(LABEL_NAME, 1));
@@ -246,6 +252,7 @@
     assertThat(q.blocking).isNull();
   }
 
+  @SuppressWarnings("deprecation")
   @Test
   public void customLabelMaxWithBlock_MaxVoteNegativeVoteBlock() throws Exception {
     saveLabelConfig(LABEL.toBuilder().setFunction(MAX_WITH_BLOCK));
@@ -265,8 +272,8 @@
   @Test
   public void customLabel_DisallowPostSubmit() throws Exception {
     saveLabelConfig(
-        LABEL.toBuilder().setFunction(NO_OP).setAllowPostSubmit(false),
-        P.toBuilder().setFunction(NO_OP));
+        LABEL.toBuilder().setNoBlockFunction().setAllowPostSubmit(false),
+        P.toBuilder().setNoBlockFunction());
 
     PushOneCommit.Result r = createChange();
     revision(r).review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
index 50fa3b2..3041744 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -33,6 +33,7 @@
 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.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
@@ -64,6 +65,7 @@
 
   @Before
   public void setUp() throws Exception {
+    removeDefaultSubmitRequirements();
     TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
     configSubmitRequirement(
         project,
@@ -242,4 +244,8 @@
                 .count())
         .isEqualTo(1);
   }
+
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 014933f..5accd00 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -694,4 +694,26 @@
     assertThat(m.body()).contains("Change subject: TRIGGER\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
+
+  @Test
+  public void watchThatUsesIsWatchedDoesntMatchAnything() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+
+    // configure a project watch for user that uses "is:watched"
+    requestScopeOperations.setApiUser(user.id());
+    watch(watchedProject, "is:watched");
+
+    // create a change as admin user that may trigger the project watch
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert that there was no email notification for user
+    assertThat(sender.getMessages()).isEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 0c24b14..b5a3b66 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -75,6 +75,7 @@
 
   @Before
   public void setUp() throws Exception {
+    removeDefaultSubmitRequirements();
     PushOneCommit.Result pushResult =
         createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
     changeData = pushResult.getChange();
@@ -976,6 +977,10 @@
         .build();
   }
 
+  private void removeDefaultSubmitRequirements() throws RestApiException {
+    gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+  }
+
   /** Submit requirement predicate that always throws an error on match. */
   static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
       implements ChangeIsOperandFactory {
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
index d0610b3..24767cb 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -22,7 +22,6 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.truth.Truth8;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -124,7 +123,7 @@
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    Truth8.assertThat(tokens).isPresent();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(10L);
 
     verify(quotaEnforcerA).availableTokens("testGroup", ctx);
@@ -139,7 +138,7 @@
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    Truth8.assertThat(tokens).isPresent();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(20L);
 
     verify(quotaEnforcerA).availableTokens("testGroup", ctx);
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
index 1f47958..772812f 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+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.RefNames;
@@ -170,6 +171,29 @@
     assertThat(statusForRuleAddFile("foo")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
   }
 
+  @Test
+  @GerritConfig(name = "rules.enable", value = "false")
+  public void prologRule_noEffectWhenRulesDisabled() throws Exception {
+    modifySubmitRules("gerrit:commit_message_matches('foo.*')");
+    String changeId = createChange().getChangeId();
+    // Default rules don't allow submission
+    assertThat(gApi.changes().id(changeId).get().submittable).isFalse();
+    // Satisfy default rules
+    approve(changeId);
+
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "rules.enable", value = "true")
+  public void prologRule_takesEffectWhenRulesEnabled() throws Exception {
+    modifySubmitRules("gerrit:commit_message_matches('foo.*')");
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    assertThat(gApi.changes().id(changeId).get().submittable).isFalse();
+  }
+
   private SubmitRecord.Status statusForRule() throws Exception {
     String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result =
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
index e62cb2b..9e9e1c2 100644
--- a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -34,109 +34,147 @@
 public class TaskListenerIT extends AbstractDaemonTest {
   /**
    * Use a LatchedMethod in a method to allow another thread to await the method's call. Once
-   * called, the Latch.call() method will block until another thread calls its LatchedMethods's
-   * complete() method.
+   * called, the call() method will block until another thread calls the complete() method or until
+   * a preset timeout is reached.
    */
-  private static class LatchedMethod {
-    private static final int AWAIT_TIMEOUT = 20;
-    private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
-
-    /** API class meant be used by the class whose method is being latched */
-    private class Latch {
-      /** Ensure that the latched method calls this on entry */
-      public void call() {
-        called.countDown();
-        await(complete);
-      }
-    }
-
-    public Latch latch = new Latch();
+  public static class LatchedMethod<T> {
+    private volatile T value;
 
     private final CountDownLatch called = new CountDownLatch(1);
     private final CountDownLatch complete = new CountDownLatch(1);
 
-    /** Assert that the Latch's call() method has not yet been called */
+    /** Assert that the call() method has not yet been called */
     public void assertUncalled() {
       assertThat(called.getCount()).isEqualTo(1);
     }
 
     /**
-     * Assert that a timeout does not occur while awaiting Latch's call() method to be called. Fails
-     * if the waiting time elapses before Latch's call() method is called, otherwise passes.
+     * Assert that a timeout does not occur while awaiting the call() to be called. Fails if the
+     * waiting time elapses before the call() method is called, otherwise passes.
      */
-    public void assertAwait() {
+    public void assertCalledEventually() {
       assertThat(await(called)).isEqualTo(true);
     }
 
-    /** Unblock the Latch's call() method so that it can complete */
+    public T call() {
+      called.countDown();
+      await(complete);
+      return getValue();
+    }
+
+    public T call(T val) {
+      set(val);
+      return call();
+    }
+
+    public T callAndAwaitComplete() throws InterruptedException {
+      called.countDown();
+      complete.await();
+      return getValue();
+    }
+
     public void complete() {
       complete.countDown();
     }
 
-    @CanIgnoreReturnValue
-    private static boolean await(CountDownLatch latch) {
-      try {
-        return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
-      } catch (InterruptedException e) {
-        return false;
-      }
+    public void set(T val) {
+      value = val;
+    }
+
+    public void complete(T val) {
+      set(val);
+      complete();
+    }
+
+    public void assertCalledEventuallyThenComplete(T val) {
+      assertCalledEventually();
+      complete(val);
+    }
+
+    protected T getValue() {
+      return value;
     }
   }
 
-  private static class LatchedRunnable implements Runnable {
-    public LatchedMethod run = new LatchedMethod();
+  public static class LatchedRunnable implements Runnable {
+    public LatchedMethod<?> run = new LatchedMethod<>();
+    public String name = "latched-runnable";
+
+    public LatchedRunnable(String name) {
+      this.name = name;
+    }
+
+    public LatchedRunnable() {}
 
     @Override
     public void run() {
-      run.latch.call();
+      run.call();
+    }
+
+    @Override
+    public String toString() {
+      return name;
     }
   }
 
-  private static class ForwardingListener implements TaskListener {
-    public volatile TaskListener delegate;
+  public static class ForwardingListener<T extends TaskListener> implements TaskListener {
+    public volatile T delegate;
     public volatile Task<?> task;
 
-    public void resetDelegate(TaskListener listener) {
+    public void resetDelegate(T listener) {
       delegate = listener;
       task = null;
     }
 
     @Override
     public void onStart(Task<?> task) {
-      if (delegate != null) {
-        if (this.task == null || this.task == task) {
-          this.task = task;
-          delegate.onStart(task);
-        }
+      if (isDelegatable(task)) {
+        delegate.onStart(task);
       }
     }
 
     @Override
     public void onStop(Task<?> task) {
+      if (isDelegatable(task)) {
+        delegate.onStop(task);
+      }
+    }
+
+    protected boolean isDelegatable(Task<?> task) {
       if (delegate != null) {
         if (this.task == task) {
-          delegate.onStop(task);
+          return true;
+        }
+        if (this.task == null) {
+          this.task = task;
+          return true;
         }
       }
+      return false;
     }
   }
 
-  private static class LatchedListener implements TaskListener {
-    public LatchedMethod onStart = new LatchedMethod();
-    public LatchedMethod onStop = new LatchedMethod();
+  public static class LatchedListener implements TaskListener {
+    public LatchedMethod<?> onStart = new LatchedMethod<>();
+    public LatchedMethod<?> onStop = new LatchedMethod<>();
 
     @Override
     public void onStart(Task<?> task) {
-      onStart.latch.call();
+      onStart.call();
     }
 
     @Override
     public void onStop(Task<?> task) {
-      onStop.latch.call();
+      onStop.call();
     }
   }
 
-  private static ForwardingListener forwarder;
+  private static final int AWAIT_TIMEOUT = 20;
+  private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+  private static final long MS_EMPTY_QUEUE =
+      TimeUnit.MILLISECONDS.convert(50, TimeUnit.MILLISECONDS);
+
+  private static ForwardingListener<TaskListener> forwarder;
 
   @Inject private WorkQueue workQueue;
   private ScheduledExecutorService executor;
@@ -149,9 +187,9 @@
     return new AbstractModule() {
       @Override
       public void configure() {
-        // Forwarder.delegate is empty on start to protect test listener from non test tasks
-        // (such as the "Log File Compressor") interference
-        forwarder = new ForwardingListener(); // Only gets bound once for all tests
+        // Forwarder.delegate is empty on start to protect test listener from non-test tasks (such
+        // as the "Log File Manager") interference
+        forwarder = new ForwardingListener<>(); // Only gets bound once for all tests
         bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
       }
     };
@@ -161,7 +199,7 @@
   public void setupExecutorAndForwarder() throws InterruptedException {
     executor = workQueue.createQueue(1, "TaskListeners");
 
-    // "Log File Compressor"s are likely running and will interfere with tests
+    // "Log File Manager"s are likely running and will interfere with tests
     while (0 != workQueue.getTasks().size()) {
       for (Task<?> t : workQueue.getTasks()) {
         @SuppressWarnings("unused")
@@ -184,23 +222,23 @@
     int size = assertQueueBlockedOnExecution(runnable);
 
     // onStartThenRunThenOnStopAreCalled -> onStart...Called
-    listener.onStart.assertAwait();
+    listener.onStart.assertCalledEventually();
     assertQueueSize(size);
     runnable.run.assertUncalled();
     listener.onStop.assertUncalled();
 
     listener.onStart.complete();
     // onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
-    runnable.run.assertAwait();
+    runnable.run.assertCalledEventually();
     listener.onStop.assertUncalled();
 
     runnable.run.complete();
     // onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
-    listener.onStop.assertAwait();
+    listener.onStop.assertCalledEventually();
     assertQueueSize(size);
 
     listener.onStop.complete();
-    assertAwaitQueueSize(--size);
+    assertTaskCountIsEventually(--size);
   }
 
   @Test
@@ -208,7 +246,7 @@
     int size = assertQueueBlockedOnExecution(runnable);
 
     // firstBlocksSecond -> first...
-    listener.onStart.assertAwait();
+    listener.onStart.assertCalledEventually();
     assertQueueSize(size);
 
     LatchedRunnable runnable2 = new LatchedRunnable();
@@ -219,35 +257,35 @@
     assertQueueSize(size); // waiting on first
 
     listener.onStart.complete();
-    runnable.run.assertAwait();
+    runnable.run.assertCalledEventually();
     assertQueueSize(size); // waiting on first
     runnable2.run.assertUncalled();
 
     runnable.run.complete();
-    listener.onStop.assertAwait();
+    listener.onStop.assertCalledEventually();
     assertQueueSize(size); // waiting on first
     runnable2.run.assertUncalled();
 
     listener.onStop.complete();
-    runnable2.run.assertAwait();
+    runnable2.run.assertCalledEventually();
     assertQueueSize(--size);
 
     runnable2.run.complete();
-    assertAwaitQueueSize(--size);
+    assertTaskCountIsEventually(--size);
   }
 
   @Test
   public void states() throws Exception {
     executor.execute(runnable);
-    listener.onStart.assertAwait();
+    listener.onStart.assertCalledEventually();
     assertStateIs(Task.State.STARTING);
 
     listener.onStart.complete();
-    runnable.run.assertAwait();
+    runnable.run.assertCalledEventually();
     assertStateIs(Task.State.RUNNING);
 
     runnable.run.complete();
-    listener.onStop.assertAwait();
+    listener.onStop.assertCalledEventually();
     assertStateIs(Task.State.STOPPING);
 
     listener.onStop.complete();
@@ -255,8 +293,40 @@
     assertStateIs(Task.State.DONE);
   }
 
+  /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+  public static void assertTaskCountIsEventually(WorkQueue workQueue, int count)
+      throws InterruptedException {
+    long ms = 0;
+    while (count != workQueue.getTasks().size()) {
+      assertThat(ms++).isLessThan(MS_EMPTY_QUEUE);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+
+  public static void assertQueueSize(WorkQueue workQueue, int size) {
+    assertThat(workQueue.getTasks().size()).isEqualTo(size);
+  }
+
+  @CanIgnoreReturnValue
+  public static boolean await(CountDownLatch latch) {
+    try {
+      return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+    } catch (InterruptedException e) {
+      return false;
+    }
+  }
+
+  public void assertTaskCountIsEventually(int count) throws InterruptedException {
+    TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+  }
+
+  public static void assertStateIs(Task<?> task, Task.State state) {
+    assertThat(task).isNotNull();
+    assertThat(task.getState()).isEqualTo(state);
+  }
+
   private void assertStateIs(Task.State state) {
-    assertThat(forwarder.task.getState()).isEqualTo(state);
+    assertStateIs(forwarder.task, state);
   }
 
   private int assertQueueBlockedOnExecution(Runnable runnable) {
@@ -267,19 +337,10 @@
   }
 
   private void assertQueueSize(int size) {
-    assertThat(workQueue.getTasks().size()).isEqualTo(size);
+    assertQueueSize(workQueue, size);
   }
 
   private void assertAwaitQueueIsEmpty() throws InterruptedException {
-    assertAwaitQueueSize(0);
-  }
-
-  /** Fails if the waiting time elapses before the count is reached, otherwise passes */
-  private void assertAwaitQueueSize(int size) throws InterruptedException {
-    long i = 0;
-    do {
-      TimeUnit.NANOSECONDS.sleep(100);
-      assertThat(i++).isLessThan(1000);
-    } while (size != workQueue.getTasks().size());
+    assertTaskCountIsEventually(0);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
new file mode 100644
index 0000000..3b82ebe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
@@ -0,0 +1,548 @@
+// 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedMethod;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedRunnable;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.git.WorkQueue.Task.State;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.gerrit.server.git.WorkQueue.TaskParker;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskParkerIT extends AbstractDaemonTest {
+  private static class ForwardingParker extends TaskListenerIT.ForwardingListener<LatchedParker>
+      implements TaskParker {
+    public AtomicInteger isReadyToStartCounter = new AtomicInteger(0);
+    public AtomicInteger onNotReadyToStartCounter = new AtomicInteger(0);
+
+    @Override
+    public boolean isReadyToStart(Task<?> task) {
+      isReadyToStartCounter.incrementAndGet();
+      if (isDelegatable(task)) {
+        return delegate.isReadyToStart(task);
+      }
+      return true;
+    }
+
+    @Override
+    public void onNotReadyToStart(Task<?> task) {
+      onNotReadyToStartCounter.incrementAndGet();
+      if (isDelegatable(task)) {
+        delegate.onNotReadyToStart(task);
+      }
+    }
+
+    public void resetCounters() {
+      isReadyToStartCounter.getAndSet(0);
+      onNotReadyToStartCounter.getAndSet(0);
+    }
+  }
+
+  public static class LatchedParker extends TaskListenerIT.LatchedListener implements TaskParker {
+    private static final String EXPENSIVE_TASK = "expensive-task";
+    private final Semaphore expensiveTaskSemaphore = new Semaphore(1, true);
+    public volatile LatchedMethod<Boolean> isReadyToStart = new LatchedMethod<>();
+    public volatile LatchedMethod<?> onNotReadyToStart = new LatchedMethod<>();
+
+    @Override
+    public boolean isReadyToStart(Task<?> task) {
+      Boolean rtn = isReadyToStart.call();
+      if (EXPENSIVE_TASK.equals(task.toString()) && !expensiveTaskSemaphore.tryAcquire()) {
+        return false;
+      }
+      isReadyToStart = new LatchedMethod<>();
+      if (rtn != null) {
+        return rtn;
+      }
+      return true;
+    }
+
+    @Override
+    public void onNotReadyToStart(Task<?> task) {
+      onNotReadyToStart.call();
+      onNotReadyToStart = new LatchedMethod<>();
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      if (EXPENSIVE_TASK.equals(task.toString())) {
+        expensiveTaskSemaphore.release();
+      }
+      super.onStop(task);
+    }
+  }
+
+  public static class LatchedForeverRunnable extends LatchedRunnable {
+    public LatchedForeverRunnable(String name) {
+      super(name);
+    }
+
+    @Override
+    public void run() {
+      try {
+        run.callAndAwaitComplete();
+      } catch (InterruptedException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  private static ForwardingParker forwarder;
+  private static ForwardingParker forwarder2;
+  public static final long TIMEOUT = TimeUnit.MILLISECONDS.convert(200, TimeUnit.MILLISECONDS);
+
+  private final LatchedParker parker = new LatchedParker();
+
+  @Inject private WorkQueue workQueue;
+  private ScheduledExecutorService executor;
+
+  @Before
+  public void setupExecutorAndForwarder() throws InterruptedException {
+    executor = workQueue.createQueue(1, "TaskParkers");
+    // "Log File Manager"s are likely running and will interfere with tests
+    while (0 != workQueue.getTasks().size()) {
+      for (Task<?> t : workQueue.getTasks()) {
+        t.cancel(true);
+      }
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+    forwarder.delegate = parker;
+    forwarder.task = null;
+    forwarder.resetCounters();
+    forwarder2.delegate = null; // load only if test needs it
+    forwarder2.task = null;
+    forwarder2.resetCounters();
+  }
+
+  @After
+  public void shutdownExecutor() throws InterruptedException {
+    executor.shutdownNow();
+    executor.awaitTermination(1, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        // Forwarder.delegate is empty on start to protect test parker from non-test tasks (such as
+        // the "Log File Manager") interference
+        forwarder = new ForwardingParker(); // Only gets bound once for all tests
+        bind(TaskListener.class).annotatedWith(Exports.named("parker")).toInstance(forwarder);
+        forwarder2 = new ForwardingParker();
+        bind(TaskListener.class).annotatedWith(Exports.named("parker2")).toInstance(forwarder2);
+      }
+    };
+  }
+
+  @Test
+  public void noParkFlow() throws Exception {
+    LatchedRunnable runnable = new LatchedRunnable();
+
+    assertTaskCountIs(0);
+    assertThat(forwarder.task).isEqualTo(null);
+    parker.isReadyToStart.assertUncalled();
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable.run.assertUncalled();
+    parker.onStop.assertUncalled();
+    assertCorePoolSizeIs(1);
+
+    executor.execute(runnable);
+    assertTaskCountIs(1);
+
+    parker.isReadyToStart.assertCalledEventually();
+    assertTaskCountIs(1);
+    assertStateIs(State.READY);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.isReadyToStart.complete();
+    parker.onStart.assertCalledEventually();
+    assertStateIs(State.STARTING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+    runnable.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.onStart.complete();
+    runnable.run.assertCalledEventually();
+    assertStateIs(State.RUNNING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    runnable.run.complete();
+    parker.onStop.assertCalledEventually();
+    assertStateIs(State.STOPPING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+
+    parker.onStop.complete();
+    assertTaskCountIsEventually(0);
+    assertStateIs(State.DONE);
+    parker.onNotReadyToStart.assertUncalled();
+    assertCorePoolSizeIs(1);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+  }
+
+  @Test
+  public void parkFirstSoSecondRuns() throws Exception {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    LatchedRunnable runnable2 = new LatchedRunnable();
+    assertCorePoolSizeIs(1);
+
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventually();
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    executor.execute(runnable2);
+    assertTaskCountIs(2);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    // park runnable1
+    parker.isReadyToStart.complete(false);
+    assertCorePoolSizeIsEventually(2);
+    assertStateIs(task1, State.PARKED);
+
+    runnable2.run.assertCalledEventually();
+    assertTaskCountIs(2);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+    assertStateIs(task1, State.PARKED);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    runnable2.run.complete();
+
+    parker.isReadyToStart.assertCalledEventually();
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.isReadyToStart.complete(true);
+    parker.onStart.assertCalledEventually();
+    assertStateIs(task1, State.STARTING);
+    assertTaskCountIsEventually(1);
+    parker.onNotReadyToStart.assertUncalled();
+    runnable1.run.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    parker.onStart.complete();
+    runnable1.run.assertCalledEventually();
+    assertStateIs(task1, State.RUNNING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+    parker.onStop.assertUncalled();
+
+    runnable1.run.complete();
+    parker.onStop.assertCalledEventually();
+    assertStateIs(task1, State.STOPPING);
+    assertTaskCountIs(1);
+    parker.onNotReadyToStart.assertUncalled();
+
+    parker.onStop.complete();
+    assertCorePoolSizeIsEventually(1);
+    assertTaskCountIsEventually(0);
+    assertStateIs(task1, State.DONE);
+    parker.onNotReadyToStart.assertUncalled();
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+  }
+
+  @Test
+  public void unParkPriorityOrder() throws Exception {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    LatchedRunnable runnable2 = new LatchedRunnable();
+    LatchedRunnable runnable3 = new LatchedRunnable();
+
+    // park runnable1
+    assertCorePoolSizeIs(1);
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertStateIsEventually(task1, State.PARKED);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    assertTaskCountIsEventually(1);
+    assertCorePoolSizeIsEventually(2);
+
+    // park runnable2
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable2);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    Task<?> task2 = forwarder.task; // task for runnable2
+    assertStateIsEventually(task2, State.PARKED);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    assertTaskCountIsEventually(2);
+    assertCorePoolSizeIsEventually(3);
+
+    // set parker to ready and execute runnable3
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable3);
+
+    // assert runnable3 finishes executing and runnable1, runnable2 stay parked
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    parker.isReadyToStart.assertCalledEventually();
+    Task<?> task3 = forwarder.task; // task for runnable3
+    assertStateIs(task3, State.READY);
+    parker.isReadyToStart.complete(true);
+    parker.onStart.assertCalledEventually();
+    assertStateIs(task3, State.STARTING);
+    parker.onStart.complete();
+    runnable3.run.assertCalledEventually();
+    assertStateIs(task3, State.RUNNING);
+    runnable1.run.assertUncalled();
+    runnable2.run.assertUncalled();
+    runnable3.run.complete();
+    parker.onStop.assertCalledEventually();
+    assertStateIs(task3, State.STOPPING);
+    parker.onStop.complete();
+    assertTaskCountIsEventually(2);
+    assertStateIs(task3, State.DONE);
+
+    // assert runnable1 finishes executing and runnable2 stays parked
+    runnable1.run.assertCalledEventually();
+    assertStateIs(task1, State.RUNNING);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 4);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    runnable2.run.assertUncalled();
+    assertStateIs(task2, State.PARKED);
+    runnable1.run.complete();
+    assertCorePoolSizeIsEventually(2);
+    assertTaskCountIsEventually(1);
+    assertStateIs(task1, State.DONE);
+
+    // assert runnable2 finishes executing
+    runnable2.run.assertCalledEventually();
+    assertStateIs(task2, State.RUNNING);
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 5);
+    assertCounter(forwarder.onNotReadyToStartCounter, 0);
+    runnable2.run.complete();
+    assertCorePoolSizeIsEventually(1);
+    assertTaskCountIsEventually(0);
+    assertStateIs(task2, State.DONE);
+  }
+
+  @Test
+  public void notReadyToStartIsCalledOnReadyListenerWhenAnotherListenerIsNotReady()
+      throws InterruptedException {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    LatchedRunnable runnable2 = new LatchedRunnable();
+
+    LatchedParker parker2 = new LatchedParker();
+    forwarder2.delegate = parker2;
+
+    // park runnable1 (parker1 is ready and parker2 is not ready)
+    assertCorePoolSizeIs(1);
+    executor.execute(runnable1);
+    parker2.isReadyToStart.complete(false);
+
+    assertTaskCountIsEventually(1);
+    assertCorePoolSizeIsEventually(2);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder2.isReadyToStartCounter, 1);
+    assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertStateIsEventually(task1, State.PARKED);
+
+    // set parker2 to ready and execute runnable-2
+    parker2.isReadyToStart.set(true);
+    forwarder.resetDelegate(parker);
+    forwarder2.resetDelegate(parker2);
+    executor.execute(runnable2);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+    assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder2.isReadyToStartCounter, 2);
+    assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+    Task<?> task2 = forwarder.task; // task for runnable2
+
+    assertCorePoolSizeIsEventually(1);
+    runnable2.run.assertCalledEventually();
+    runnable2.run.complete();
+    assertTaskCountIsEventually(1);
+    assertStateIs(task2, State.DONE);
+
+    assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+    assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+    assertCounterIsEventually(forwarder2.isReadyToStartCounter, 3);
+    assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+
+    runnable1.run.assertCalledEventually();
+    runnable1.run.complete();
+    assertTaskCountIsEventually(0);
+    assertStateIs(task1, State.DONE);
+  }
+
+  @Test
+  public void runFirstParkSecondUsingTaskName() throws InterruptedException {
+    LatchedForeverRunnable runnable1 = new LatchedForeverRunnable("expensive-task");
+    LatchedRunnable runnable2 = new LatchedRunnable("expensive-task");
+    LatchedParker parker = new LatchedParker();
+    executor = workQueue.createQueue(2, "TaskParkers");
+    assertCorePoolSizeIs(2);
+
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable1);
+    parker.isReadyToStart.complete();
+    parker.onStart.complete();
+    runnable1.run.assertCalledEventually();
+    assertTaskCountIsEventually(1);
+    assertCorePoolSizeIs(2);
+    Task<?> task1 = forwarder.task; // task for runnable1
+    assertStateIs(task1, State.RUNNING);
+
+    forwarder.resetDelegate(parker);
+    executor.execute(runnable2);
+    parker.isReadyToStart.assertCalledEventually();
+    assertCorePoolSizeIsEventually(3);
+    Task<?> task2 = forwarder.task; // task for runnable2
+    assertStateIs(task2, State.PARKED);
+
+    forwarder.resetDelegate(parker);
+    runnable1.run.complete(); // unblock runnable1
+
+    assertCorePoolSizeIsEventually(2);
+    assertTaskCountIsEventually(0); // assert both tasks finish
+  }
+
+  @Test
+  public void interruptingParkedTaskDecrementsCorePoolSize() throws InterruptedException {
+    String taskName = "to-be-parked";
+    LatchedRunnable runnable1 = new LatchedRunnable(taskName);
+    assertCorePoolSizeIs(1);
+
+    // park runnable1
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    assertCorePoolSizeIsEventually(2);
+    assertStateIsEventually(forwarder.task, State.PARKED);
+
+    // interrupt the thread with parked task
+    for (Thread t : Thread.getAllStackTraces().keySet()) {
+      if (t.getName().contains(taskName)) {
+        t.interrupt();
+        break;
+      }
+    }
+
+    assertCorePoolSizeIsEventually(1);
+  }
+
+  @Test
+  public void canCancelParkedTask() throws InterruptedException {
+    LatchedRunnable runnable1 = new LatchedRunnable();
+    assertCorePoolSizeIs(1);
+
+    // park runnable1
+    executor.execute(runnable1);
+    parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+    assertCorePoolSizeIsEventually(2);
+    Task<?> task = forwarder.task;
+    assertStateIsEventually(task, State.PARKED);
+
+    // cancel parked task
+    task.cancel(true);
+
+    // assert core pool size is reduced and task is cancelled
+    assertCorePoolSizeIsEventually(1);
+    assertTaskCountIsEventually(0);
+    assertStateIs(State.CANCELLED);
+  }
+
+  private void assertTaskCountIs(int size) {
+    TaskListenerIT.assertQueueSize(workQueue, size);
+  }
+
+  private void assertTaskCountIsEventually(int count) throws InterruptedException {
+    TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+  }
+
+  private void assertCorePoolSizeIs(int count) {
+    assertThat(count).isEqualTo(((ScheduledThreadPoolExecutor) executor).getCorePoolSize());
+  }
+
+  private void assertCorePoolSizeIsEventually(int count) throws InterruptedException {
+    ScheduledThreadPoolExecutor scheduledThreadPoolExecutor =
+        (ScheduledThreadPoolExecutor) executor;
+    long ms = 0;
+    while (count != scheduledThreadPoolExecutor.getCorePoolSize()) {
+      assertThat(ms++).isLessThan(TIMEOUT);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+
+  private void assertCounter(AtomicInteger counter, int desiredCount) {
+    assertThat(counter.get()).isEqualTo(desiredCount);
+  }
+
+  private void assertCounterIsEventually(AtomicInteger counter, int desiredCount)
+      throws InterruptedException {
+    long ms = 0;
+    while (desiredCount != counter.get()) {
+      assertThat(ms++).isLessThan(TIMEOUT);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+
+  private void assertStateIs(Task.State state) {
+    TaskListenerIT.assertStateIs(forwarder.task, state);
+  }
+
+  private void assertStateIs(Task<?> task, Task.State state) {
+    TaskListenerIT.assertStateIs(task, state);
+  }
+
+  private void assertStateIsEventually(Task<?> task, Task.State state) throws InterruptedException {
+    long ms = 0;
+    assertThat(task).isNotNull();
+    while (!task.getState().equals(state)) {
+      assertThat(ms++).isLessThan(TIMEOUT);
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 2a06900..c24c1f2 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -28,6 +28,8 @@
 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.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.util.List;
@@ -39,6 +41,8 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
 
+  @Inject private ChangeIndexCollection changeIndexes;
+
   @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
   public void indexChange() throws Exception {
@@ -109,4 +113,19 @@
       assertThat(ids).doesNotContain(change.getId().get());
     }
   }
+
+  @Test
+  public void testNumDocs() throws Exception {
+    testNumDocs(changeIndexes.getSearchIndex());
+    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
+      testNumDocs(i);
+    }
+  }
+
+  private void testNumDocs(ChangeIndex index) throws Exception {
+    int before = index.numDocs();
+    createChange("a change", "a.txt", "test");
+    int after = index.numDocs();
+    assertThat(after).isEqualTo(before + 1);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index 84c3936..8153a5d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.time.Instant;
 import org.junit.Test;
 
 @UseSsh
@@ -123,8 +124,8 @@
     private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
-      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    public void logNanos(String operation, long durationNanos, Instant endTime, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, endTime, metadata));
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
@@ -134,12 +135,14 @@
 
   @AutoValue
   abstract static class PerformanceLogEntry {
-    static PerformanceLogEntry create(String operation, Metadata metadata) {
-      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, metadata);
+    static PerformanceLogEntry create(String operation, Instant endTime, Metadata metadata) {
+      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, endTime, metadata);
     }
 
     abstract String operation();
 
+    abstract Instant endTime();
+
     abstract Metadata metadata();
   }
 }
diff --git a/javatests/com/google/gerrit/auth/BUILD b/javatests/com/google/gerrit/auth/BUILD
index fa30f80a..6a18174 100644
--- a/javatests/com/google/gerrit/auth/BUILD
+++ b/javatests/com/google/gerrit/auth/BUILD
@@ -12,7 +12,6 @@
     runtime_deps = [
         "//java/com/google/gerrit/lucene",
         "//lib/bouncycastle:bcprov",
-        "//prolog:gerrit-prolog-common",
     ],
     deps = [
         "//java/com/google/gerrit/auth",
diff --git a/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java
new file mode 100644
index 0000000..c14e9261
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Objects;
+import org.junit.Test;
+
+public class AccountInputProtoConverterTest {
+  private final AccountInputProtoConverter accountInputProtoConverter =
+      AccountInputProtoConverter.INSTANCE;
+
+  private AccountInput createAccountInputInstance() {
+    AccountInput accountInput = new AccountInput();
+    accountInput.username = "test-username";
+    accountInput.name = "test-name";
+    accountInput.displayName = "test-display-name";
+    accountInput.email = "test-email@gmail.com";
+    accountInput.sshKey = "test-ssh-key";
+    accountInput.httpPassword = "test-http-password";
+    accountInput.groups = List.of("group1", "group2");
+    return accountInput;
+  }
+
+  private void assertAccountInputEquals(AccountInput expected, AccountInput actual) {
+    assertThat(
+            Objects.equals(expected.username, actual.username)
+                && Objects.equals(expected.name, actual.name)
+                && Objects.equals(expected.displayName, actual.displayName)
+                && Objects.equals(expected.email, actual.email)
+                && Objects.equals(expected.sshKey, actual.sshKey)
+                && Objects.equals(expected.httpPassword, actual.httpPassword)
+                && Objects.equals(expected.groups, actual.groups))
+        .isTrue();
+  }
+
+  @Test
+  public void allValuesConvertedToProto() {
+
+    Entities.AccountInput proto = accountInputProtoConverter.toProto(createAccountInputInstance());
+
+    Entities.AccountInput expectedProto =
+        Entities.AccountInput.newBuilder()
+            .setUsername("test-username")
+            .setName("test-name")
+            .setDisplayName("test-display-name")
+            .setEmail("test-email@gmail.com")
+            .setSshKey("test-ssh-key")
+            .setHttpPassword("test-http-password")
+            .addAllGroups(ImmutableList.of("group1", "group2"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    AccountInput accountInput = createAccountInputInstance();
+
+    AccountInput convertedaccountInput =
+        accountInputProtoConverter.fromProto(accountInputProtoConverter.toProto(accountInput));
+
+    assertAccountInputEquals(accountInput, convertedaccountInput);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(AccountInput.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("username", String.class)
+                .put("name", String.class)
+                .put("displayName", String.class)
+                .put("email", String.class)
+                .put("sshKey", String.class)
+                .put("httpPassword", String.class)
+                .put("groups", new TypeLiteral<List<String>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
new file mode 100644
index 0000000..47d0d9a
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import java.lang.reflect.Type;
+import java.util.Objects;
+import org.junit.Test;
+
+public class ApplyPatchInputProtoConverterTest {
+  private final ApplyPatchInputProtoConverter applyPatchInputProtoConverter =
+      ApplyPatchInputProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = "test-patch";
+    applyPatchInput.allowConflicts = true;
+    Entities.ApplyPatchInput proto = applyPatchInputProtoConverter.toProto(applyPatchInput);
+
+    Entities.ApplyPatchInput expectedProto =
+        Entities.ApplyPatchInput.newBuilder()
+            .setPatch("test-patch")
+            .setAllowConflicts(true)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = "test-patch";
+    applyPatchInput.allowConflicts = true;
+
+    ApplyPatchInput convertedApplyPatchInput =
+        applyPatchInputProtoConverter.fromProto(
+            applyPatchInputProtoConverter.toProto(applyPatchInput));
+
+    assertThat(Objects.equals(applyPatchInput.patch, convertedApplyPatchInput.patch)).isTrue();
+    assertThat(applyPatchInput.allowConflicts).isEqualTo(convertedApplyPatchInput.allowConflicts);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(ApplyPatchInput.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("patch", String.class)
+                .put("allowConflicts", Boolean.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 0ca9478..804397b 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -4,16 +4,19 @@
     name = "proto_converter_tests",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//lib:guava",
+        "//lib:guava-testlib",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/guice",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
         "//proto:entities_java_proto",
+        "@commons-lang3//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
new file mode 100644
index 0000000..ffa99d9
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.junit.Test;
+
+public class ChangeInputProtoConverterTest {
+  private final ChangeInputProtoConverter changeInputProtoConverter =
+      ChangeInputProtoConverter.INSTANCE;
+  private final MergeInputProtoConverter mergeInputProtoConverter =
+      MergeInputProtoConverter.INSTANCE;
+  private final AccountInputProtoConverter accountInputProtoConverter =
+      AccountInputProtoConverter.INSTANCE;
+
+  // Helper method that creates a MergeInput with all possible value.
+  private MergeInput createMergeInput() {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "test-source";
+    mergeInput.sourceBranch = "test-source-branch";
+    mergeInput.strategy = "test-strategy";
+    mergeInput.allowConflicts = true;
+    return mergeInput;
+  }
+
+  // Helper method that creates a AccountInput with all possible value.
+  private AccountInput createAccountInput() {
+    AccountInput accountInput = new AccountInput();
+    accountInput.username = "test-username";
+    accountInput.displayName = "test-displayName";
+    accountInput.name = "test-name";
+    accountInput.email = "test-email";
+    accountInput.sshKey = "test-ssh-key";
+    accountInput.httpPassword = "test-http-password";
+    accountInput.groups = ImmutableList.of("test-group");
+    return accountInput;
+  }
+
+  // Helper method that creates a ChangeInput with all possible value.
+  private ChangeInput createChangeInput() {
+    ChangeInput changeInput = new ChangeInput("test-project", "test-branch", "test-subject");
+    changeInput.topic = "test-topic";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.isPrivate = true;
+    changeInput.workInProgress = true;
+    changeInput.baseChange = "test-base-change";
+    changeInput.baseCommit = "test-base-commit";
+    changeInput.newBranch = true;
+
+    Map<String, String> validationOptions = new HashMap<>();
+    validationOptions.put("test-key", "test-value");
+    changeInput.validationOptions = validationOptions;
+
+    Map<String, String> customKeyedValues = new HashMap<>();
+    customKeyedValues.put("test-key", "test-value");
+    changeInput.customKeyedValues = customKeyedValues;
+
+    changeInput.merge = createMergeInput();
+
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = "test-patch";
+    changeInput.patch = applyPatchInput;
+
+    changeInput.author = createAccountInput();
+
+    changeInput.responseFormatOptions = new ArrayList<>();
+    changeInput.responseFormatOptions.addAll(
+        ImmutableList.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_LABELS));
+
+    changeInput.notify = NotifyHandling.OWNER;
+
+    Map<RecipientType, NotifyInfo> notifyDetails = new HashMap<>();
+    NotifyInfo notifyInfo = new NotifyInfo(ImmutableList.of("account1", "account2"));
+    notifyDetails.put(RecipientType.TO, notifyInfo);
+    changeInput.notifyDetails = notifyDetails;
+    return changeInput;
+  }
+
+  private void assertAccountInputEquals(AccountInput expected, AccountInput actual) {
+    assertThat(
+            (expected == null && actual == null)
+                || (Objects.equals(expected.username, actual.username)
+                    && Objects.equals(expected.name, actual.name)
+                    && Objects.equals(expected.displayName, actual.displayName)
+                    && Objects.equals(expected.email, actual.email)
+                    && Objects.equals(expected.sshKey, actual.sshKey)
+                    && Objects.equals(expected.httpPassword, actual.httpPassword)
+                    && Objects.equals(expected.groups, actual.groups)))
+        .isTrue();
+  }
+
+  private void assertMergeInputEquals(MergeInput expected, MergeInput actual) {
+    assertThat(
+            (expected == null && actual == null)
+                || (Objects.equals(expected.source, actual.source)
+                    && Objects.equals(expected.sourceBranch, actual.sourceBranch)
+                    && Objects.equals(expected.strategy, actual.strategy)
+                    && expected.allowConflicts == actual.allowConflicts))
+        .isTrue();
+  }
+
+  private void assertChangeInputEquals(ChangeInput expected, ChangeInput actual) {
+    assertThat(
+            Objects.equals(expected.project, actual.project)
+                && Objects.equals(expected.branch, actual.branch)
+                && Objects.equals(expected.subject, actual.subject)
+                && Objects.equals(expected.topic, actual.topic)
+                && Objects.equals(expected.status, actual.status)
+                && Objects.equals(expected.isPrivate, actual.isPrivate)
+                && Objects.equals(expected.workInProgress, actual.workInProgress)
+                && Objects.equals(expected.baseChange, actual.baseChange)
+                && Objects.equals(expected.baseCommit, actual.baseCommit)
+                && Objects.equals(expected.newBranch, actual.newBranch)
+                && Objects.equals(expected.validationOptions, actual.validationOptions)
+                && Objects.equals(expected.customKeyedValues, actual.customKeyedValues)
+                && Objects.equals(expected.responseFormatOptions, actual.responseFormatOptions)
+                && Objects.equals(expected.notify, actual.notify)
+                && Objects.equals(expected.notifyDetails, actual.notifyDetails))
+        .isTrue();
+    assertThat(
+            (expected.patch == null && actual.patch == null)
+                || Objects.equals(expected.patch.patch, actual.patch.patch))
+        .isTrue();
+    assertAccountInputEquals(expected.author, actual.author);
+    assertMergeInputEquals(expected.merge, actual.merge);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    ChangeInput changeInput = new ChangeInput("test-project", "test-branch", "test-subject");
+
+    Entities.ChangeInput proto = changeInputProtoConverter.toProto(changeInput);
+
+    Entities.ChangeInput expectedProto =
+        Entities.ChangeInput.newBuilder()
+            .setProject("test-project")
+            .setBranch("test-branch")
+            .setSubject("test-subject")
+            .setNotify(Entities.NotifyHandling.ALL)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProto() {
+
+    Entities.ChangeInput proto = changeInputProtoConverter.toProto(createChangeInput());
+
+    Entities.ChangeInput.Builder expectedProto =
+        Entities.ChangeInput.newBuilder()
+            .setProject("test-project")
+            .setBranch("test-branch")
+            .setSubject("test-subject")
+            .setTopic("test-topic")
+            .setStatus(Entities.ChangeStatus.NEW)
+            .setBaseChange("test-base-change")
+            .setBaseCommit("test-base-commit")
+            .setNewBranch(true)
+            .setIsPrivate(true)
+            .setWorkInProgress(true)
+            .setPatch(Entities.ApplyPatchInput.newBuilder().setPatch("test-patch").build());
+
+    Map<String, String> validationOptions = new HashMap<>();
+    validationOptions.put("test-key", "test-value");
+    expectedProto.putAllValidationOptions(validationOptions);
+
+    Map<String, String> customKeyedValues = new HashMap<>();
+    customKeyedValues.put("test-key", "test-value");
+    expectedProto.putAllCustomKeyedValues(customKeyedValues);
+
+    expectedProto.setMerge(mergeInputProtoConverter.toProto(createMergeInput()));
+    expectedProto.setAuthor(accountInputProtoConverter.toProto(createAccountInput()));
+
+    expectedProto.addAllResponseFormatOptions(
+        ImmutableList.of(
+            Entities.ListChangesOption.LABELS, Entities.ListChangesOption.DETAILED_LABELS));
+    expectedProto.setNotify(Entities.NotifyHandling.OWNER);
+    Map<String, Entities.NotifyInfo> notifyDetailsProto = new HashMap<>();
+    Entities.NotifyInfo.Builder notifyInfoBuilder =
+        Entities.NotifyInfo.newBuilder().addAllAccounts(ImmutableList.of("account1", "account2"));
+    notifyDetailsProto.put(RecipientType.TO.name(), notifyInfoBuilder.build());
+    expectedProto.putAllNotifyDetails(notifyDetailsProto);
+
+    assertThat(proto).isEqualTo(expectedProto.build());
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    ChangeInput changeInput = new ChangeInput("test-project", "test-branch", "test-subject");
+
+    ChangeInput convertedChangeInput =
+        changeInputProtoConverter.fromProto(changeInputProtoConverter.toProto(changeInput));
+
+    assertChangeInputEquals(changeInput, convertedChangeInput);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeInput changeInput = createChangeInput();
+
+    ChangeInput convertedChangeInput =
+        changeInputProtoConverter.fromProto(changeInputProtoConverter.toProto(changeInput));
+
+    assertChangeInputEquals(changeInput, convertedChangeInput);
+  }
+
+  /**
+   * See {@link com.google.gerrit.proto.testing.SerializedClassSubject} for background and what to
+   * do if this test fails.
+   */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(ChangeInput.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("project", String.class)
+                .put("branch", String.class)
+                .put("subject", String.class)
+                .put("topic", String.class)
+                .put("status", ChangeStatus.class)
+                .put("isPrivate", Boolean.class)
+                .put("workInProgress", Boolean.class)
+                .put("baseChange", String.class)
+                .put("baseCommit", String.class)
+                .put("newBranch", Boolean.class)
+                .put("validationOptions", new TypeLiteral<Map<String, String>>() {}.getType())
+                .put("customKeyedValues", new TypeLiteral<Map<String, String>>() {}.getType())
+                .put("merge", MergeInput.class)
+                .put("patch", ApplyPatchInput.class)
+                .put("author", AccountInput.class)
+                .put(
+                    "responseFormatOptions",
+                    new TypeLiteral<List<ListChangesOption>>() {}.getType())
+                .put("notify", NotifyHandling.class)
+                .put(
+                    "notifyDetails", new TypeLiteral<Map<RecipientType, NotifyInfo>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bbf10bd..512aac9 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -32,6 +32,7 @@
 
 public class ChangeProtoConverterTest {
   private final ChangeProtoConverter changeProtoConverter = ChangeProtoConverter.INSTANCE;
+  private static final String TEST_SERVER_ID = "test-server-id";
 
   @Test
   public void allValuesConvertedToProto() {
@@ -42,6 +43,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
     change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
@@ -76,6 +78,7 @@
             .setWorkInProgress(true)
             .setReviewStarted(true)
             .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
+            .setServerId(TEST_SERVER_ID)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -124,6 +127,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
     // O as ID actually means that no current patch set is present.
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
 
@@ -147,6 +151,7 @@
             .setIsPrivate(false)
             .setWorkInProgress(false)
             .setReviewStarted(false)
+            .setServerId(TEST_SERVER_ID)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -161,6 +166,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
@@ -184,12 +190,13 @@
             .setIsPrivate(false)
             .setWorkInProgress(false)
             .setReviewStarted(false)
+            .setServerId(TEST_SERVER_ID)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
   @Test
-  public void allValuesConvertedToProtoAndBackAgain() {
+  public void allValuesConvertedToProtoAndBackAgainExceptNullServerId() {
     Change change =
         new Change(
             Change.key("change 1"),
@@ -197,6 +204,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(null);
     change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
@@ -275,6 +283,7 @@
         .hasFields(
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
+                .put("serverId", String.class)
                 .put("changeKey", Change.Key.class)
                 .put("createdOn", Instant.class)
                 .put("lastUpdatedOn", Instant.class)
@@ -298,6 +307,7 @@
   // an AutoValue.
   private static void assertEqualChange(Change change, Change expectedChange) {
     assertThat(change.getChangeId()).isEqualTo(expectedChange.getChangeId());
+    assertThat(change.getServerId()).isEqualTo(expectedChange.getServerId());
     assertThat(change.getKey()).isEqualTo(expectedChange.getKey());
     assertThat(change.getCreatedOn()).isEqualTo(expectedChange.getCreatedOn());
     assertThat(change.getLastUpdatedOn()).isEqualTo(expectedChange.getLastUpdatedOn());
diff --git a/javatests/com/google/gerrit/entities/converter/MergeInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/MergeInputProtoConverterTest.java
new file mode 100644
index 0000000..d625545
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/MergeInputProtoConverterTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import java.lang.reflect.Type;
+import java.util.Objects;
+import org.junit.Test;
+
+public class MergeInputProtoConverterTest {
+  private final MergeInputProtoConverter mergeInputProtoConverter =
+      MergeInputProtoConverter.INSTANCE;
+
+  // Helper method that creates a MergeInput with all possible value.
+  private MergeInput createMergeInput() {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "test-source";
+    mergeInput.sourceBranch = "test-source-branch";
+    mergeInput.strategy = "test-strategy";
+    mergeInput.allowConflicts = true;
+    return mergeInput;
+  }
+
+  private void assertMergeInputEquals(MergeInput expected, MergeInput actual) {
+    assertThat(
+            Objects.equals(expected.source, actual.source)
+                && Objects.equals(expected.sourceBranch, actual.sourceBranch)
+                && Objects.equals(expected.strategy, actual.strategy)
+                && expected.allowConflicts == actual.allowConflicts)
+        .isTrue();
+  }
+
+  @Test
+  public void allValuesConvertedToProto() {
+
+    Entities.MergeInput proto = mergeInputProtoConverter.toProto(createMergeInput());
+
+    Entities.MergeInput expectedProto =
+        Entities.MergeInput.newBuilder()
+            .setSource("test-source")
+            .setSourceBranch("test-source-branch")
+            .setStrategy("test-strategy")
+            .setAllowConflicts(true)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    MergeInput mergeInput = createMergeInput();
+
+    MergeInput convertedMergeInput =
+        mergeInputProtoConverter.fromProto(mergeInputProtoConverter.toProto(mergeInput));
+
+    assertMergeInputEquals(mergeInput, convertedMergeInput);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(MergeInput.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("source", String.class)
+                .put("sourceBranch", String.class)
+                .put("strategy", String.class)
+                .put("allowConflicts", boolean.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/NotifyInfoProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/NotifyInfoProtoConverterTest.java
new file mode 100644
index 0000000..db72350
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/NotifyInfoProtoConverterTest.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.List;
+import org.junit.Test;
+
+public class NotifyInfoProtoConverterTest {
+  private final NotifyInfoProtoConverter notifyInfoProtoConverter =
+      NotifyInfoProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    NotifyInfo notifyInfo = new NotifyInfo(ImmutableList.of("account1", "account2"));
+    Entities.NotifyInfo proto = notifyInfoProtoConverter.toProto(notifyInfo);
+
+    Entities.NotifyInfo expectedProto =
+        Entities.NotifyInfo.newBuilder()
+            .addAllAccounts(ImmutableList.of("account1", "account2"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    NotifyInfo notifyInfo = new NotifyInfo(ImmutableList.of("account1", "account2"));
+
+    NotifyInfo convertedNotifyInfo =
+        notifyInfoProtoConverter.fromProto(notifyInfoProtoConverter.toProto(notifyInfo));
+
+    assertThat(convertedNotifyInfo).isEqualTo(notifyInfo);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(NotifyInfo.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("accounts", new TypeLiteral<List<String>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
new file mode 100644
index 0000000..eb69d53
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
@@ -0,0 +1,286 @@
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Primitives;
+import com.google.common.reflect.ClassPath;
+import com.google.common.reflect.ClassPath.ClassInfo;
+import com.google.common.testing.ArbitraryInstances;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Message;
+import com.google.protobuf.MessageLite;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  SafeProtoConverterTest.ListSafeProtoConverterTest.class, //
+  SafeProtoConverterTest.PerTypeSafeProtoConverterTest.class, //
+})
+public class SafeProtoConverterTest {
+  public static class ListSafeProtoConverterTest {
+    @Test
+    public void areAllConvertersEnums() throws Exception {
+      Stream<? extends Class<?>> safeConverters =
+          ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+              .filter(type -> type.getPackageName().contains("gerrit"))
+              .map(ClassInfo::load)
+              .filter(SafeProtoConverter.class::isAssignableFrom)
+              .filter(clz -> !SafeProtoConverter.class.equals(clz));
+      // Safe converters must be enums. See also `isConverterAValidEnum` test below.
+      assertThat(safeConverters.allMatch(Class::isEnum)).isTrue();
+    }
+  }
+
+  @RunWith(Parameterized.class)
+  public static class PerTypeSafeProtoConverterTest {
+    @Parameter(0)
+    public SafeProtoConverter<Message, Object> converter;
+
+    @Parameter(1)
+    public String converterName;
+
+    @Parameters(name = "PerTypeSafeProtoConverterTest${1}")
+    public static ImmutableList<Object[]> listSafeConverters() throws Exception {
+      return ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+          .filter(type -> type.getPackageName().contains("gerrit"))
+          .map(ClassInfo::load)
+          .filter(SafeProtoConverter.class::isAssignableFrom)
+          .filter(clz -> !SafeProtoConverter.class.equals(clz))
+          .filter(Class::isEnum)
+          .map(clz -> (SafeProtoConverter<Message, Object>) clz.getEnumConstants()[0])
+          .map(clz -> new Object[] {clz, clz.getClass().getSimpleName()})
+          .collect(toImmutableList());
+    }
+
+    /**
+     * For rising visibility, all Java Entity classes which have a {@link SafeProtoConverter}, must
+     * be annotated with {@link ConvertibleToProto}.
+     */
+    @Test
+    public void isJavaClassMarkedAsConvertibleToProto() {
+      assertThat(converter.getEntityClass().getDeclaredAnnotation(ConvertibleToProto.class))
+          .isNotNull();
+    }
+
+    /**
+     * All {@link SafeProtoConverter} implementations must be enums with a single instance. Please
+     * prefer descriptive enum and instance names, such as {@code
+     * MyTypeConverter::MY_TYPE_CONVERTER}.
+     */
+    @Test
+    public void isConverterAValidEnum() {
+      assertThat(converter.getClass().isEnum()).isTrue();
+      assertThat(converter.getClass().getEnumConstants().length).isEqualTo(1);
+    }
+
+    /**
+     * If this test fails, it's likely that you added a field to a Java class that has a {@link
+     * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+     * update the corresponding proto accordingly.
+     */
+    @Test
+    public void javaDefaultsKeptOnDoubleConversion() {
+      Object orig;
+      try {
+        orig = buildObjectWithFullFieldsOrThrow(converter.getEntityClass());
+      } catch (Exception e) {
+        throw new IllegalStateException(
+            String.format(
+                "Failed to build object for type %s, this likely means the buildObjectWithFullFieldsOrThrow should be adapted.",
+                converter.getEntityClass().getName()),
+            e);
+      }
+      Object res = converter.fromProto(converter.toProto(converter.getEntityClass().cast(orig)));
+      assertThat(orig).isEqualTo(res);
+      // If this assertion fails, it's likely that you forgot to update the `equals` method to
+      // include your new field.
+      assertThat(EqualsBuilder.reflectionEquals(orig, res)).isTrue();
+    }
+
+    /**
+     * If this test fails, it's likely that you added a field to a proto that has a {@link
+     * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+     * update the corresponding Java class accordingly.
+     */
+    @Test
+    public void protoDefaultsKeptOnDoubleConversion() {
+      Message defaultInstance = getProtoDefaultInstance(converter.getProtoClass());
+      Message preFilled = explicitlyFillProtoDefaults(defaultInstance);
+      Message resFromDefault =
+          converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+      Message resFromPrefilled =
+          converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+      assertThat(resFromDefault).isEqualTo(preFilled);
+      assertThat(resFromPrefilled).isEqualTo(preFilled);
+    }
+
+    @Nullable
+    private static Object buildObjectWithFullFieldsOrThrow(Class<?> clz) throws Exception {
+      if (clz == null) {
+        return null;
+      }
+      Object obj = construct(clz);
+      if (isSimple(clz)) {
+        return obj;
+      }
+      for (Field field : clz.getDeclaredFields()) {
+        if (Modifier.isStatic(field.getModifiers())) {
+          continue;
+        }
+        Class<?> parameterizedType = getParameterizedType(field);
+        if (!field.getType().isArray()
+            && !Map.class.isAssignableFrom(field.getType())
+            && !Collection.class.isAssignableFrom(field.getType())) {
+          if (!field.trySetAccessible()) {
+            return null;
+          }
+          field.set(obj, buildObjectWithFullFieldsOrThrow(field.getType()));
+        } else if (Collection.class.isAssignableFrom(field.getType())
+            && parameterizedType != null) {
+          field.set(obj, ImmutableList.of(buildObjectWithFullFieldsOrThrow(parameterizedType)));
+        }
+      }
+      return obj;
+    }
+
+    /**
+     * AutoValue annotations are not retained on runtime. We can only find out if a class is an
+     * AutoValue, by trying to load the expected AutoValue class.
+     *
+     * <p>For the class {@code package.Clz}, the AutoValue class name is {@code
+     * package.AutoValue_Clz}, for {@code package.Enclosing$Clz}, it is {@code
+     * package.AutoValue_Enclosing_Clz}
+     */
+    static Optional<Class<?>> toRepresentingAutoValueClass(Class<?> clz) {
+      String origClzName = clz.getName();
+      String autoValueClzName =
+          origClzName.substring(0, origClzName.lastIndexOf("."))
+              + ".AutoValue_"
+              + origClzName.substring(origClzName.lastIndexOf(".") + 1);
+      autoValueClzName = autoValueClzName.replace('$', '_');
+      try {
+        return Optional.of(clz.getClassLoader().loadClass(autoValueClzName));
+      } catch (Exception e) {
+        return Optional.empty();
+      }
+    }
+
+    @Nullable
+    private static Class<?> getParameterizedType(Field field) {
+      if (!Collection.class.isAssignableFrom(field.getType())) {
+        return null;
+      }
+      Type genericType = field.getGenericType();
+      if (genericType instanceof ParameterizedType) {
+        return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+      }
+      return null;
+    }
+
+    @Nonnull
+    static Object construct(@Nonnull Class<?> clz) {
+      try {
+        Object arbitrary = ArbitraryInstances.get(clz);
+        if (arbitrary != null) {
+          return arbitrary;
+        }
+        Optional<Class<?>> optionalAutoValueRepresentation = toRepresentingAutoValueClass(clz);
+        if (optionalAutoValueRepresentation.isPresent()) {
+          return construct(optionalAutoValueRepresentation.get());
+        }
+        Constructor<?> constructor =
+            Arrays.stream(clz.getDeclaredConstructors())
+                // Filter out copy constructors
+                .filter(
+                    c ->
+                        c.getParameterCount() != 1
+                            || !c.getParameterTypes()[0].isAssignableFrom(clz))
+                // Filter out private constructors which cannot be set accessible.
+                .filter(c -> c.canAccess(null) || c.trySetAccessible())
+                .min(Comparator.comparingInt(Constructor::getParameterCount))
+                .get();
+        List<Object> args = new ArrayList<>();
+        for (Class<?> f : constructor.getParameterTypes()) {
+          args.add(construct(f));
+        }
+        return constructor.newInstance(args.toArray());
+      } catch (Exception e) {
+        throw new IllegalStateException("Failed to construct class " + clz.getName(), e);
+      }
+    }
+
+    static boolean isSimple(Class<?> c) {
+      return c.isPrimitive()
+          || c.isEnum()
+          || Primitives.isWrapperType(c)
+          || String.class.isAssignableFrom(c)
+          || Timestamp.class.isAssignableFrom(c);
+    }
+
+    /**
+     * Returns the default instance for the given MessageLite class, if it has the {@code
+     * getDefaultInstance} static method.
+     *
+     * @param type the protobuf message class
+     * @throws IllegalArgumentException if the given class doesn't have the static {@code
+     *     getDefaultInstance} method
+     */
+    public static <T extends MessageLite> T getProtoDefaultInstance(Class<T> type) {
+      try {
+        return type.cast(type.getMethod("getDefaultInstance").invoke(null));
+      } catch (ReflectiveOperationException | ClassCastException e) {
+        throw new IllegalStateException("Cannot get default instance for " + type, e);
+      }
+    }
+
+    private static Message explicitlyFillProtoDefaults(Message defaultInstance) {
+      Message.Builder res = defaultInstance.toBuilder();
+      for (FieldDescriptor f : defaultInstance.getDescriptorForType().getFields()) {
+        try {
+          if (f.getType().equals(FieldDescriptor.Type.MESSAGE)) {
+            if (f.isRepeated()) {
+              res.addRepeatedField(
+                  f,
+                  explicitlyFillProtoDefaults(
+                      explicitlyFillProtoDefaults(
+                          getProtoDefaultInstance(res.newBuilderForField(f).build().getClass()))));
+            } else {
+              res.setField(f, explicitlyFillProtoDefaults((Message) defaultInstance.getField(f)));
+            }
+          } else {
+            res.setField(f, defaultInstance.getField(f));
+          }
+        } catch (Exception e) {
+          throw new IllegalStateException("Failed to fill default instance for " + f.getName(), e);
+        }
+      }
+      return res.build();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 1bb39c8..86fd295 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib/guice",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
new file mode 100644
index 0000000..828f6c1
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.registration;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.inject.AbstractModule;
+import com.google.inject.Binder;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.ProvisionException;
+import com.google.inject.TypeLiteral;
+import java.util.function.Consumer;
+import org.junit.Test;
+
+public class DynamicItemTest {
+  private static final String PLUGIN_NAME = "plugin-name";
+
+  private static final String UNEXPECTED_PLUGIN_NAME = "unexpected-plugin";
+  private static final String DYNAMIC_ITEM_1 = "item-1";
+  private static final String DYNAMIC_ITEM_2 = "item-2";
+  private static final TypeLiteral<String> STRING_TYPE_LITERAL = new TypeLiteral<>() {};
+  private static final TypeLiteral<FinalItemApi> FINAL_ITEM_API_TYPE_LITERAL =
+      new TypeLiteral<>() {};
+  private static final TypeLiteral<FinalItemApiForPlugin>
+      FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL = new TypeLiteral<>() {};
+
+  @DynamicItem.Final
+  private interface FinalItemApi {}
+
+  private static class FinalItemImpl implements FinalItemApi {
+    private static final FinalItemApi INSTANCE = new FinalItemImpl();
+  }
+
+  @DynamicItem.Final(implementedByPlugin = PLUGIN_NAME)
+  private interface FinalItemApiForPlugin {}
+
+  private static class FinalItemImplByPlugin implements FinalItemApiForPlugin {
+    private static final FinalItemApiForPlugin INSTANCE = new FinalItemImplByPlugin();
+  }
+
+  @Test
+  public void shouldAssignDynamicItemTwice() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(STRING_TYPE_LITERAL, DynamicItem.itemOf(String.class, null));
+
+    ImmutableList<RegistrationHandle> gerritRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) -> {
+                  DynamicItem.itemOf(binder, String.class);
+                  DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_1);
+                }),
+            PluginName.GERRIT,
+            bindings);
+    assertThat(gerritRegistrations).hasSize(1);
+    assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_1, PluginName.GERRIT);
+
+    ImmutableList<RegistrationHandle> pluginRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) -> DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_2)),
+            PLUGIN_NAME,
+            bindings);
+    assertThat(pluginRegistrations).hasSize(1);
+    assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_2, PLUGIN_NAME);
+  }
+
+  @Test
+  public void shouldFailToAssignFinalDynamicItemTwice() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(FINAL_ITEM_API_TYPE_LITERAL, DynamicItem.itemOf(FinalItemApi.class, null));
+
+    ImmutableList<RegistrationHandle> baseInjectorRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) -> {
+                  DynamicItem.itemOf(binder, FinalItemApi.class);
+                  DynamicItem.bind(binder, FinalItemApi.class).toInstance(FinalItemImpl.INSTANCE);
+                }),
+            PluginName.GERRIT,
+            bindings);
+    assertThat(baseInjectorRegistrations).hasSize(1);
+
+    ProvisionException ignored =
+        assertThrows(
+            ProvisionException.class,
+            () -> {
+              ImmutableList<RegistrationHandle> unused =
+                  PrivateInternals_DynamicTypes.attachItems(
+                      newInjector(
+                          (binder) ->
+                              DynamicItem.bind(binder, FinalItemApi.class)
+                                  .toInstance(FinalItemImpl.INSTANCE)),
+                      PluginName.GERRIT,
+                      bindings);
+            });
+  }
+
+  @Test
+  public void shouldFailToAssignFinalDynamicItemToDifferentPlugin() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(
+            FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL,
+            DynamicItem.itemOf(FinalItemApi.class, null));
+
+    assertThrows(
+        ProvisionException.class,
+        () -> {
+          ImmutableList<RegistrationHandle> unused =
+              PrivateInternals_DynamicTypes.attachItems(
+                  newInjector(
+                      (binder) ->
+                          DynamicItem.bind(binder, FinalItemApiForPlugin.class)
+                              .toInstance(FinalItemImplByPlugin.INSTANCE)),
+                  UNEXPECTED_PLUGIN_NAME,
+                  bindings);
+        });
+  }
+
+  @Test
+  public void shouldAssignFinalDynamicItemToExpectedPlugin() {
+    ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+        ImmutableMap.of(
+            FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL,
+            DynamicItem.itemOf(FinalItemApi.class, null));
+
+    ImmutableList<RegistrationHandle> pluginRegistrations =
+        PrivateInternals_DynamicTypes.attachItems(
+            newInjector(
+                (binder) ->
+                    DynamicItem.bind(binder, FinalItemApiForPlugin.class)
+                        .toInstance(FinalItemImplByPlugin.INSTANCE)),
+            PLUGIN_NAME,
+            bindings);
+    assertThat(pluginRegistrations).hasSize(1);
+    assertDynamicItem(
+        bindings.get(FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL),
+        FinalItemImplByPlugin.INSTANCE,
+        PLUGIN_NAME);
+  }
+
+  private static Injector newInjector(Consumer<Binder> binding) {
+    return Guice.createInjector(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            binding.accept(binder());
+          }
+        });
+  }
+
+  private static <T> void assertDynamicItem(
+      @Nullable DynamicItem<?> item, T itemVal, String pluginName) {
+    assertThat(item).isNotNull();
+    assertThat(item.get()).isEqualTo(itemVal);
+    assertThat(item.getPluginName()).isEqualTo(pluginName);
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 6f8e73a..2f6d565 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -96,7 +96,7 @@
   }
 
   @Test
-  public void usePreloadRest() throws Exception {
+  public void usePreloadRestWithBasePatchNum() throws Exception {
     Accounts accountsApi = mock(Accounts.class);
     when(accountsApi.self()).thenThrow(new AuthException("user needs to be authenticated"));
 
@@ -114,12 +114,43 @@
     when(gerritApi.accounts()).thenReturn(accountsApi);
     when(gerritApi.config()).thenReturn(configApi);
 
-    assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
+    String requestedPath = "/c/project/+/123/4..6";
+    assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(4);
+
+    assertThat(dynamicTemplateData(gerritApi, requestedPath, ""))
         .containsAtLeast(
             "defaultChangeDetailHex", "9996394",
             "changeRequestsPath", "changes/project~123");
   }
 
+  @Test
+  public void usePreloadRestWithNoBasePatchNum() throws Exception {
+    Accounts accountsApi = mock(Accounts.class);
+    when(accountsApi.self()).thenThrow(new AuthException("user needs to be authenticated"));
+
+    Server serverApi = mock(Server.class);
+    when(serverApi.getVersion()).thenReturn("123");
+    when(serverApi.topMenus()).thenReturn(ImmutableList.of());
+    ServerInfo serverInfo = new ServerInfo();
+    serverInfo.defaultTheme = "my-default-theme";
+    when(serverApi.getInfo()).thenReturn(serverInfo);
+
+    Config configApi = mock(Config.class);
+    when(configApi.server()).thenReturn(serverApi);
+
+    GerritApi gerritApi = mock(GerritApi.class);
+    when(gerritApi.accounts()).thenReturn(accountsApi);
+    when(gerritApi.config()).thenReturn(configApi);
+
+    String requestedPath = "/c/project/+/123";
+    assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(0);
+
+    assertThat(dynamicTemplateData(gerritApi, requestedPath, ""))
+        .containsAtLeast(
+            "defaultChangeDetailHex", "1996394",
+            "changeRequestsPath", "changes/project~123");
+  }
+
   private static SanitizedContent ordain(String s) {
     return UnsafeSanitizedContentOrdainer.ordainAsSafe(
         s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index 06ea8b6..d582b4b 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -89,10 +89,10 @@
     assertThat(output)
         .contains(
             "window.INITIAL_DATA = JSON.parse("
-                + "'\\x7b\\x22\\/config\\/server\\/version\\x22: \\x22123\\x22, "
-                + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
-                + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
-                + "\\x5b\\x5d\\x7d');");
+                + "'\\x7b\\x22foo-url\\/config\\/server\\/top-menus\\x22: \\x5b\\x5d, "
+                + "\\x22foo-url\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
+                + "\\x22my-default-theme\\x22\\x7d, \\x22foo-url\\/config\\/server\\/version\\x22: "
+                + "\\x22123\\x22\\x7d');");
     ImmutableSet<String> enabledDefaults =
         ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
             .collect(ImmutableSet.toImmutableSet());
diff --git a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
index 28ec30d..e973a26 100644
--- a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
@@ -15,19 +15,33 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.raw.StaticModule.PolyGerritFilter.isPolyGerritIndex;
 
-import com.google.common.collect.ImmutableList;
 import org.junit.Test;
 
 public class StaticModuleTest {
 
   @Test
   public void doNotMatchPolyGerritIndex() {
-    ImmutableList.of(
-            "/c/123456/anyString",
-            "/123456/anyString",
-            "/c/123456/comment/9ab75172_67d798e1",
-            "/123456/comment/9ab75172_67d798e1")
-        .forEach(url -> assertThat(StaticModule.PolyGerritFilter.isPolyGerritIndex(url)).isFalse());
+    assertThat(isPolyGerritIndex("/123456")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1/")).isFalse();
+    assertThat(isPolyGerritIndex("/c/123456/comment/9ab75172_67d798e1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/comment/9ab75172_67d798e1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/comment/9ab75172_67d798e1/")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1..2")).isFalse();
+    assertThat(isPolyGerritIndex("/c/123456/1..2")).isFalse();
+    assertThat(isPolyGerritIndex("/c/2/1/COMMIT_MSG")).isFalse();
+    assertThat(isPolyGerritIndex("/c/2/1/path/to/source/file/MyClass.java")).isFalse();
+  }
+
+  @Test
+  public void matchPolyGerritIndex() {
+    assertThat(isPolyGerritIndex("/c/test/+/123456/anyString")).isTrue();
+    assertThat(isPolyGerritIndex("/c/test/+/123456/comment/9ab75172_67d798e1")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/+/123456/anyString")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/+/123456/comment/9ab75172_67d798e1")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/anyString")).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index b2be85f..7d40042 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -110,12 +110,12 @@
         outputPath);
     try (InputStream fi = Files.newInputStream(outputPath);
         InputStream bi = new BufferedInputStream(fi);
-        ArchiveInputStream archive = archiveStreamForFormat(bi, format)) {
+        ArchiveInputStream<?> archive = archiveStreamForFormat(bi, format)) {
       assertEntries(archive);
     }
   }
 
-  private ArchiveInputStream archiveStreamForFormat(InputStream bi, String format)
+  private ArchiveInputStream<?> archiveStreamForFormat(InputStream bi, String format)
       throws IOException {
     switch (format) {
       case "zip":
@@ -207,7 +207,7 @@
     commit = gApi.changes().id(changeId).current().commit(false);
   }
 
-  private void assertEntries(ArchiveInputStream o) throws IOException {
+  private void assertEntries(ArchiveInputStream<?> o) throws IOException {
     Set<String> entryNames = new TreeSet<>();
     ArchiveEntry e;
     while ((e = o.getNextEntry()) != null) {
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index 0fe4fad..43b86cb 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/pgm/http/jetty",
         "//java/com/google/gerrit/pgm/init/api",
+        "//java/com/google/gerrit/pgm/util",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/securestore/testing",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java b/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java
new file mode 100644
index 0000000..b3f59cc
--- /dev/null
+++ b/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.config.SitePaths;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class LogFileManagerTest {
+
+  @Test
+  public void testLogFilePattern() throws Exception {
+    List<String> filenamesWithDate =
+        List.of(
+            "error_log.2024-01-01",
+            "error_log.2024-01-01.gz",
+            "error_log.json.2024-01-01",
+            "error_log.json.2024-01-01.gz",
+            "sshd_log.2024-01-01",
+            "httpd_log.2024-01-01");
+
+    List<String> filenamesWithoutDate =
+        List.of(
+            "error_log",
+            "error_log.gz",
+            "error_log.json",
+            "error_log.json.gz",
+            "sshd_log",
+            "httpd_log");
+
+    LogFileManager manager = new LogFileManager(new SitePaths(Path.of("/gerrit")), new Config());
+    Instant expected = Instant.parse("2024-01-01T00:00:00.00Z");
+    for (String filename : filenamesWithDate) {
+      assertThat(manager.getDateFromFilename(Path.of(filename)).get()).isEqualTo(expected);
+    }
+
+    for (String filename : filenamesWithoutDate) {
+      assertThat(manager.getDateFromFilename(Path.of(filename)).isEmpty()).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 46027a2..f726be3 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -103,6 +103,7 @@
     Account account =
         Account.builder(Account.id(1), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
+            .setUniqueTag("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 6628362..45eff9a 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -41,12 +41,15 @@
 
   @Test
   public void account_roundTrip() throws Exception {
+    // The uniqueTag and metaId can be different (in google internal implementation).
+    // This tests ensures that they are serialized/deserialized separately.
     Account account =
         Account.builder(Account.id(1), Instant.EPOCH)
             .setFullName("foo bar")
             .setDisplayName("foo")
             .setActive(false)
             .setMetaId("dead..beef")
+            .setUniqueTag("dead..beef..tag")
             .setStatus("OOO")
             .setPreferredEmail("foo@bar.tld")
             .build();
@@ -63,6 +66,7 @@
                     .setDisplayName("foo")
                     .setInactive(true)
                     .setMetaId("dead..beef")
+                    .setUniqueTag("dead..beef..tag")
                     .setStatus("OOO")
                     .setPreferredEmail("foo@bar.tld"))
             .build();
@@ -71,6 +75,39 @@
   }
 
   @Test
+  public void account_deserializeOldRecordWithoutUniqueTag() throws Exception {
+    Account.Builder builder =
+        Account.builder(Account.id(1), Instant.EPOCH)
+            .setFullName("foo bar")
+            .setDisplayName("foo")
+            .setActive(false)
+            .setMetaId("dead..beef")
+            .setStatus("OOO")
+            .setPreferredEmail("foo@bar.tld");
+    CachedAccountDetails original =
+        CachedAccountDetails.create(builder.build(), ImmutableMap.of(), CachedPreferences.EMPTY);
+    CachedAccountDetails expected =
+        CachedAccountDetails.create(
+            builder.setUniqueTag("dead..beef").build(), ImmutableMap.of(), CachedPreferences.EMPTY);
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expectedProto =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(
+                Cache.AccountProto.newBuilder()
+                    .setId(1)
+                    .setRegisteredOn(0)
+                    .setFullName("foo bar")
+                    .setDisplayName("foo")
+                    .setInactive(true)
+                    .setMetaId("dead..beef")
+                    .setStatus("OOO")
+                    .setPreferredEmail("foo@bar.tld"))
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expectedProto);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(expected);
+  }
+
+  @Test
   public void account_roundTripNullFields() throws Exception {
     CachedAccountDetails original =
         CachedAccountDetails.create(ACCOUNT, ImmutableMap.of(), CachedPreferences.EMPTY);
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 34f746a..a53abfa 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -351,6 +351,7 @@
     return AccountState.forAccount(
         Account.builder(Account.id(id), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
+            .setUniqueTag("1234567812345678123456781234567812345678")
             .build());
   }
 
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 8c4eb08..b7f566db 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -72,7 +72,8 @@
         1 << 20,
         expireAfterWrite,
         refreshAfterWrite,
-        true);
+        true,
+        false);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
index effc801..8991a6a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/JavaCacheSerializerTest.java
@@ -43,7 +43,7 @@
   abstract static class MyType implements Serializable {
     private static final long serialVersionUID = 1L;
 
-    abstract Integer anInt();
+    abstract int anInt();
 
     abstract String aString();
   }
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 01537e0..0c451ac 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -20,13 +20,33 @@
 import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.change.ChangeKindCacheImpl.NoCache;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeKindCacheImplTest {
+  private InMemoryRepositoryManager repoManager;
+  private ChangeKindCache changeKindCache;
+
+  @Before
+  public void setUp() {
+    repoManager = new InMemoryRepositoryManager();
+    // For simplicity, we use non-caching version, and as long as we call the method that doesn't
+    // use ChangeData, we can provide null instead of constructing a factory.
+    changeKindCache = new NoCache(new Config(), null, repoManager);
+  }
+
   @Test
   public void keySerializer() throws Exception {
     ChangeKindCacheImpl.Key key =
@@ -57,4 +77,134 @@
             ImmutableMap.of(
                 "prior", ObjectId.class, "next", ObjectId.class, "strategyName", String.class));
   }
+
+  @Test
+  public void commitMessageChanged() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().create();
+    RevCommit firstRev = p.commit().parent(root).message("Commit message").create();
+    RevCommit secondRev = p.commit().parent(root).message("Commit message update").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+        .isEqualTo(ChangeKind.NO_CODE_CHANGE);
+  }
+
+  @Test
+  public void sameObject_noChange() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().create();
+    RevCommit rev =
+        p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, rev, rev))
+        .isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  @Test
+  public void sameContent_noChange() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().create();
+    RevCommit firstRev =
+        p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+    RevCommit secondRev =
+        p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+        .isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  @Test
+  public void contentChanged_rework() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().create();
+    RevCommit firstRev =
+        p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+    RevCommit secondRev =
+        p.commit().parent(root).message("Commit message").add("test.md", "Goodbye").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+        .isEqualTo(ChangeKind.REWORK);
+  }
+
+  @Test
+  public void mergeConflict_rework() throws Exception {
+    // Delete a change in one of the parents
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().add("foo", "foo-text").create();
+    RevCommit firstRev =
+        p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+    // File was deleted, but the commit is still writing new content to it.
+    RevCommit newRoot = p.commit().parent(root).rm("foo").create();
+    RevCommit secondRev =
+        p.commit().parent(newRoot).message("Commit message").add("foo", "bar-text").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+        .isEqualTo(ChangeKind.REWORK);
+  }
+
+  @Test
+  public void rebaseThenEdit_rework() throws Exception {
+    // Delete a change in one of the parents
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().add("foo", "foo-text").create();
+    RevCommit firstRev =
+        p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+    // Unrelated file was added.
+    RevCommit newRoot = p.commit().parent(root).add("baz", "baz-text").create();
+    RevCommit secondRev =
+        p.commit().parent(newRoot).message("Commit message").add("foo", "foobar-text").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+        .isEqualTo(ChangeKind.REWORK);
+  }
+
+  @Test
+  public void trivialRebase() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().add("foo", "foo-text").create();
+    RevCommit firstRev =
+        p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+    // Unrelated file was added.
+    RevCommit newRoot = p.commit().parent(root).add("baz", "baz-text").create();
+    RevCommit secondRev =
+        p.commit().parent(newRoot).message("Commit message").add("foo", "bar-text").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+        .isEqualTo(ChangeKind.TRIVIAL_REBASE);
+  }
+
+  @Test
+  public void trivialRebaseCommitMessage() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit root = p.commit().add("foo", "foo-text").create();
+    RevCommit firstRev =
+        p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+    // Unrelated file was added.
+    RevCommit newRoot = p.commit().parent(root).add("baz", "baz-text").create();
+    RevCommit secondRev =
+        p.commit().parent(newRoot).message("Commit subject").add("foo", "bar-text").create();
+
+    assertThat(
+            changeKindCache.getChangeKind(
+                p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+        .isEqualTo(ChangeKind.TRIVIAL_REBASE_WITH_MESSAGE_UPDATE);
+  }
+
+  private TestRepository<Repo> newRepo(String name) throws Exception {
+    return new TestRepository<>(repoManager.createRepository(Project.nameKey(name)));
+  }
 }
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
index 772f4b8..131fb23 100644
--- a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
+++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -130,6 +130,79 @@
   }
 
   @Test
+  public void userPreferencesProto_falseValueReturnsAsNull() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setEditPreferencesInfo(
+                UserPreferences.EditPreferencesInfo.newBuilder()
+                    .setTabSize(17)
+                    .setHideTopMenu(false)
+                    .setHideLineNumbers(false)
+                    .setAutoCloseBrackets(true))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref);
+
+    assertThat(edit.tabSize).isEqualTo(17);
+    assertThat(edit.hideTopMenu).isNull();
+    assertThat(edit.hideLineNumbers).isNull();
+    assertThat(edit.autoCloseBrackets).isTrue();
+  }
+
+  @Test
+  public void bothPreferencesTypes_getGeneralPreferencesAreEqual() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(19))
+            .build();
+    Config originalCfg = new Config();
+    originalCfg.fromText("[general]\n\tchangesPerPage = 19");
+
+    CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    GeneralPreferencesInfo protoGeneral = CachedPreferences.general(Optional.empty(), protoPref);
+    CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+    GeneralPreferencesInfo cfgGeneral = CachedPreferences.general(Optional.empty(), cfgPref);
+
+    assertThat(protoGeneral).isEqualTo(cfgGeneral);
+  }
+
+  @Test
+  public void bothPreferencesTypes_getDiffPreferencesAreEqual() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setDiffPreferencesInfo(UserPreferences.DiffPreferencesInfo.newBuilder().setContext(23))
+            .build();
+    Config originalCfg = new Config();
+    originalCfg.fromText("[diff]\n\tcontext = 23");
+
+    CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    DiffPreferencesInfo protoDiff = CachedPreferences.diff(Optional.empty(), protoPref);
+    CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+    DiffPreferencesInfo cfgDiff = CachedPreferences.diff(Optional.empty(), cfgPref);
+
+    assertThat(protoDiff).isEqualTo(cfgDiff);
+  }
+
+  @Test
+  public void bothPreferencesTypes_getEditPreferencesAreEqual() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setEditPreferencesInfo(UserPreferences.EditPreferencesInfo.newBuilder().setTabSize(27))
+            .build();
+    Config originalCfg = new Config();
+    originalCfg.fromText("[edit]\n\ttabSize = 27");
+
+    CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    EditPreferencesInfo protoEdit = CachedPreferences.edit(Optional.empty(), protoPref);
+    CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+    EditPreferencesInfo cfgEdit = CachedPreferences.edit(Optional.empty(), cfgPref);
+
+    assertThat(protoEdit).isEqualTo(cfgEdit);
+  }
+
+  @Test
   public void defaultPreferences_acceptingGitConfig() throws Exception {
     Config cfg = new Config();
     cfg.fromText("[general]\n\tchangesPerPage = 19");
diff --git a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
index e40ccfc..8d93f66 100644
--- a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.truth.Truth8;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.nio.file.Path;
@@ -148,7 +147,7 @@
 
   @Test
   public void basePathWhenNotConfigured() {
-    Truth8.assertThat(repoCfg.getBasePath(Project.nameKey("someProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject"))).isNull();
   }
 
   @Test
@@ -162,7 +161,7 @@
   public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    Truth8.assertThat(repoCfg.getBasePath(Project.nameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someOtherProject"))).isNull();
     assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
index 6ecf549..1de6c30 100644
--- a/javatests/com/google/gerrit/server/config/SitePathsTest.java
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.common.truth.Truth8;
 import com.google.gerrit.server.ioutil.HostPlatform;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -31,8 +30,8 @@
     final Path root = random();
     final SitePaths site = new SitePaths(root);
     assertThat(site.isNew).isTrue();
-    Truth8.assertThat(site.site_path).isEqualTo(root);
-    Truth8.assertThat(site.etc_dir).isEqualTo(root.resolve("etc"));
+    assertThat(site.site_path).isEqualTo(root);
+    assertThat(site.etc_dir).isEqualTo(root.resolve("etc"));
   }
 
   @Test
@@ -43,7 +42,7 @@
 
       final SitePaths site = new SitePaths(root);
       assertThat(site.isNew).isTrue();
-      Truth8.assertThat(site.site_path).isEqualTo(root);
+      assertThat(site.site_path).isEqualTo(root);
     } finally {
       Files.delete(root);
     }
@@ -59,7 +58,7 @@
 
       final SitePaths site = new SitePaths(root);
       assertThat(site.isNew).isFalse();
-      Truth8.assertThat(site.site_path).isEqualTo(root);
+      assertThat(site.site_path).isEqualTo(root);
     } finally {
       Files.delete(txt);
       Files.delete(root);
@@ -83,15 +82,15 @@
     final Path root = random();
     final SitePaths site = new SitePaths(root);
 
-    Truth8.assertThat(site.resolve(null)).isNull();
-    Truth8.assertThat(site.resolve("")).isNull();
+    assertThat(site.resolve(null)).isNull();
+    assertThat(site.resolve("")).isNull();
 
-    Truth8.assertThat(site.resolve("a")).isNotNull();
-    Truth8.assertThat(site.resolve("a")).isEqualTo(root.resolve("a").toAbsolutePath().normalize());
+    assertThat(site.resolve("a")).isNotNull();
+    assertThat(site.resolve("a")).isEqualTo(root.resolve("a").toAbsolutePath().normalize());
 
     final String pfx = HostPlatform.isWin32() ? "C:/" : "/";
-    Truth8.assertThat(site.resolve(pfx + "a")).isNotNull();
-    Truth8.assertThat(site.resolve(pfx + "a")).isEqualTo(Path.of(pfx + "a"));
+    assertThat(site.resolve(pfx + "a")).isNotNull();
+    assertThat(site.resolve(pfx + "a")).isEqualTo(Path.of(pfx + "a"));
   }
 
   private static Path random() throws IOException {
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
index c6ca3e4..16be1e4 100644
--- a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -18,6 +18,9 @@
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter.DIFF_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter.EDIT_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter.GENERAL_PREFERENCES_INFO_CONVERTER;
 import static java.util.Arrays.stream;
 
 import com.google.common.collect.ImmutableList;
@@ -35,9 +38,6 @@
 import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.MenuItem;
 import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.Theme;
 import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.TimeFormat;
-import com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter;
-import com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter;
-import com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter;
 import com.google.protobuf.Descriptors.Descriptor;
 import com.google.protobuf.Descriptors.EnumDescriptor;
 import java.util.EnumSet;
@@ -70,6 +70,36 @@
     }
   }
 
+  /**
+   * If this test fails, it's likely that you added a field to {@link GeneralPreferencesInfo}, or
+   * that you have changed the default value for such a field. Please update the {@link
+   * com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo} proto accordingly.
+   */
+  @Test
+  public void generalPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+    GeneralPreferencesInfo orig = GeneralPreferencesInfo.defaults();
+    GeneralPreferencesInfo res =
+        GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(
+            GENERAL_PREFERENCES_INFO_CONVERTER.toProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
+  /**
+   * If this test fails, it's likely that you added a field to {@link
+   * com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo}, or that you have
+   * changed the default value for such a field. Please update the {@link GeneralPreferencesInfo}
+   * class accordingly.
+   */
+  @Test
+  public void generalPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+    UserPreferences.GeneralPreferencesInfo orig =
+        UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
+    UserPreferences.GeneralPreferencesInfo res =
+        GENERAL_PREFERENCES_INFO_CONVERTER.toProto(
+            GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
   @Test
   public void generalPreferencesInfo_doubleConversionWithAllFieldsSet() {
     UserPreferences.GeneralPreferencesInfo originalProto =
@@ -112,22 +142,68 @@
             .setDiffPageSidebar("plugin-insight")
             .build();
     UserPreferences.GeneralPreferencesInfo resProto =
-        GeneralPreferencesInfoConverter.toProto(
-            GeneralPreferencesInfoConverter.fromProto(originalProto));
+        GENERAL_PREFERENCES_INFO_CONVERTER.toProto(
+            GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
     assertThat(resProto).isEqualTo(originalProto);
   }
 
   @Test
+  public void generalPreferencesInfo_toProtoTrimsMyMenuSpaces() {
+    GeneralPreferencesInfo info = new GeneralPreferencesInfo();
+    info.my =
+        ImmutableList.of(
+            new com.google.gerrit.extensions.client.MenuItem(
+                " name1 ", " url1 ", " target1 ", " id1 "),
+            new com.google.gerrit.extensions.client.MenuItem(null, " url2 ", null, null));
+    UserPreferences.GeneralPreferencesInfo resProto =
+        GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
+    assertThat(resProto)
+        .isEqualTo(
+            UserPreferences.GeneralPreferencesInfo.newBuilder()
+                .addAllMyMenuItems(
+                    ImmutableList.of(
+                        MenuItem.newBuilder()
+                            .setUrl("url1")
+                            .setName("name1")
+                            .setTarget("target1")
+                            .setId("id1")
+                            .build(),
+                        MenuItem.newBuilder().setUrl("url2").build()))
+                .build());
+  }
+
+  @Test
+  public void generalPreferencesInfo_fromProtoTrimsMyMenuSpaces() {
+    UserPreferences.GeneralPreferencesInfo originalProto =
+        UserPreferences.GeneralPreferencesInfo.newBuilder()
+            .addAllMyMenuItems(
+                ImmutableList.of(
+                    MenuItem.newBuilder()
+                        .setName(" name1 ")
+                        .setUrl(" url1 ")
+                        .setTarget(" target1 ")
+                        .setId(" id1 ")
+                        .build(),
+                    MenuItem.newBuilder().setUrl(" url2 ").build()))
+            .build();
+    GeneralPreferencesInfo info = GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(originalProto);
+    assertThat(info.my)
+        .containsExactly(
+            new com.google.gerrit.extensions.client.MenuItem("name1", "url1", "target1", "id1"),
+            new com.google.gerrit.extensions.client.MenuItem(null, "url2", null, null));
+  }
+
+  @Test
   public void generalPreferencesInfo_emptyJavaToProto() {
     GeneralPreferencesInfo info = new GeneralPreferencesInfo();
-    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    UserPreferences.GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res).isEqualToDefaultInstance();
   }
 
   @Test
   public void generalPreferencesInfo_defaultJavaToProto() {
     GeneralPreferencesInfo info = GeneralPreferencesInfo.defaults();
-    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    UserPreferences.GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res)
         .ignoringFieldAbsence()
         .isEqualTo(UserPreferences.GeneralPreferencesInfo.getDefaultInstance());
@@ -137,7 +213,7 @@
   public void generalPreferencesInfo_emptyProtoToJava() {
     UserPreferences.GeneralPreferencesInfo proto =
         UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
-    GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.fromProto(proto);
+    GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(proto);
     assertThat(res).isEqualTo(new GeneralPreferencesInfo());
   }
 
@@ -163,6 +239,34 @@
     }
   }
 
+  /**
+   * If this test fails, it's likely that you added a field to {@link DiffPreferencesInfo}, or that
+   * you have changed the default value for such a field. Please update the {@link
+   * com.google.gerrit.proto.Entities.UserPreferences.DiffPreferencesInfo} proto accordingly.
+   */
+  @Test
+  public void diffPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+    DiffPreferencesInfo orig = DiffPreferencesInfo.defaults();
+    DiffPreferencesInfo res =
+        DIFF_PREFERENCES_INFO_CONVERTER.fromProto(DIFF_PREFERENCES_INFO_CONVERTER.toProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
+  /**
+   * If this test fails, it's likely that you added a field to {@link
+   * com.google.gerrit.proto.Entities.UserPreferences.DiffPreferencesInfo}, or that you have changed
+   * the default value for such a field. Please update the {@link DiffPreferencesInfo} class
+   * accordingly.
+   */
+  @Test
+  public void diffPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+    UserPreferences.DiffPreferencesInfo orig =
+        UserPreferences.DiffPreferencesInfo.getDefaultInstance();
+    UserPreferences.DiffPreferencesInfo res =
+        DIFF_PREFERENCES_INFO_CONVERTER.toProto(DIFF_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
   @Test
   public void diffPreferencesInfo_doubleConversionWithAllFieldsSet() {
     UserPreferences.DiffPreferencesInfo originalProto =
@@ -193,21 +297,22 @@
             .setSkipUncommented(false)
             .build();
     UserPreferences.DiffPreferencesInfo resProto =
-        DiffPreferencesInfoConverter.toProto(DiffPreferencesInfoConverter.fromProto(originalProto));
+        DIFF_PREFERENCES_INFO_CONVERTER.toProto(
+            DIFF_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
     assertThat(resProto).isEqualTo(originalProto);
   }
 
   @Test
   public void diffPreferencesInfo_emptyJavaToProto() {
     DiffPreferencesInfo info = new DiffPreferencesInfo();
-    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    UserPreferences.DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res).isEqualToDefaultInstance();
   }
 
   @Test
   public void diffPreferencesInfo_defaultJavaToProto() {
     DiffPreferencesInfo info = DiffPreferencesInfo.defaults();
-    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    UserPreferences.DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res)
         .ignoringFieldAbsence()
         .isEqualTo(UserPreferences.DiffPreferencesInfo.getDefaultInstance());
@@ -217,7 +322,7 @@
   public void diffPreferencesInfo_emptyProtoToJava() {
     UserPreferences.DiffPreferencesInfo proto =
         UserPreferences.DiffPreferencesInfo.getDefaultInstance();
-    DiffPreferencesInfo res = DiffPreferencesInfoConverter.fromProto(proto);
+    DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.fromProto(proto);
     assertThat(res).isEqualTo(new DiffPreferencesInfo());
   }
 
@@ -243,6 +348,34 @@
     }
   }
 
+  /**
+   * If this test fails, it's likely that you added a field to {@link EditPreferencesInfo}, or that
+   * you have changed the default value for such a field. Please update the {@link
+   * com.google.gerrit.proto.Entities.UserPreferences.EditPreferencesInfo} proto accordingly.
+   */
+  @Test
+  public void editPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+    EditPreferencesInfo orig = EditPreferencesInfo.defaults();
+    EditPreferencesInfo res =
+        EDIT_PREFERENCES_INFO_CONVERTER.fromProto(EDIT_PREFERENCES_INFO_CONVERTER.toProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
+  /**
+   * If this test fails, it's likely that you added a field to {@link
+   * com.google.gerrit.proto.Entities.UserPreferences.EditPreferencesInfo}, or that you have changed
+   * the default value for such a field. Please update the {@link EditPreferencesInfo} class
+   * accordingly.
+   */
+  @Test
+  public void editPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+    UserPreferences.EditPreferencesInfo orig =
+        UserPreferences.EditPreferencesInfo.getDefaultInstance();
+    UserPreferences.EditPreferencesInfo res =
+        EDIT_PREFERENCES_INFO_CONVERTER.toProto(EDIT_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+    assertThat(res).isEqualTo(orig);
+  }
+
   @Test
   public void editPreferencesInfo_doubleConversionWithAllFieldsSet() {
     UserPreferences.EditPreferencesInfo originalProto =
@@ -263,21 +396,22 @@
             .setShowBase(false)
             .build();
     UserPreferences.EditPreferencesInfo resProto =
-        EditPreferencesInfoConverter.toProto(EditPreferencesInfoConverter.fromProto(originalProto));
+        EDIT_PREFERENCES_INFO_CONVERTER.toProto(
+            EDIT_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
     assertThat(resProto).isEqualTo(originalProto);
   }
 
   @Test
   public void editPreferencesInfo_emptyJavaToProto() {
     EditPreferencesInfo info = new EditPreferencesInfo();
-    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    UserPreferences.EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res).isEqualToDefaultInstance();
   }
 
   @Test
   public void editPreferencesInfo_defaultJavaToProto() {
     EditPreferencesInfo info = EditPreferencesInfo.defaults();
-    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    UserPreferences.EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.toProto(info);
     assertThat(res)
         .ignoringFieldAbsence()
         .isEqualTo(UserPreferences.EditPreferencesInfo.getDefaultInstance());
@@ -287,7 +421,7 @@
   public void editPreferencesInfo_emptyProtoToJava() {
     UserPreferences.EditPreferencesInfo proto =
         UserPreferences.EditPreferencesInfo.getDefaultInstance();
-    EditPreferencesInfo res = EditPreferencesInfoConverter.fromProto(proto);
+    EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.fromProto(proto);
     assertThat(res).isEqualTo(new EditPreferencesInfo());
   }
 
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentNoEOLTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentNoEOLTest.java
index dd36e3a..b6c5d47 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentNoEOLTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentNoEOLTest.java
@@ -276,6 +276,30 @@
   }
 
   @Test
+  public void replaceLastLine() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 0, 3, 10, "Abc\ndef");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nAbc\ndef");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 10, 0, 7);
+  }
+
+  @Test
+  public void replaceLastLineEndLineNotExists() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 0, 4, 0, "Abc\ndef");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nAbc\ndef");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 10, 0, 7);
+  }
+
+  @Test
   public void replaceWholeContent() throws Exception {
     FixResult fixResult =
         FixCalculatorVariousTest.calculateFixSingleReplacement(
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 05965fb..14554c4 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -59,12 +59,12 @@
       Ref ref1 = createRefWithNonEmptyTreeCommit(usersRepo, 1, 1000001);
       Ref ref2 = createRefWithEmptyTreeCommit(usersRepo, 1, 1000002);
 
-      DeleteZombieCommentsRefs clean =
+      try (DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
-      int deletedDrafts = clean.execute();
-      assertThat(deletedDrafts).isEqualTo(1);
-
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {})) {
+        int deletedDrafts = clean.execute();
+        assertThat(deletedDrafts).isEqualTo(1);
+      }
       /* Check that ref1 still exists, and ref2 is deleted */
       assertThat(usersRepo.exactRef(ref1.getName())).isNotNull();
       assertThat(usersRepo.exactRef(ref2.getName())).isNull();
@@ -81,21 +81,21 @@
       assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(3);
 
       int cleanupPercentage = 50;
-      DeleteZombieCommentsRefs clean =
+      try (DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
-      int deletedDrafts = clean.execute();
-      assertThat(deletedDrafts).isEqualTo(1);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {})) {
+        int deletedDrafts = clean.execute();
+        assertThat(deletedDrafts).isEqualTo(1);
+        /* ref1 not deleted, ref2 deleted, ref3 not deleted because of the clean percentage */
+        assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(2);
+        assertThat(usersRepo.exactRef(ref1.getName())).isNotNull();
+        assertThat(usersRepo.exactRef(ref2.getName())).isNull();
+        assertThat(usersRepo.exactRef(ref3.getName())).isNotNull();
 
-      /* ref1 not deleted, ref2 deleted, ref3 not deleted because of the clean percentage */
-      assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(2);
-      assertThat(usersRepo.exactRef(ref1.getName())).isNotNull();
-      assertThat(usersRepo.exactRef(ref2.getName())).isNull();
-      assertThat(usersRepo.exactRef(ref3.getName())).isNotNull();
-
-      /* Re-execute the cleanup and make sure nothing's changed */
-      deletedDrafts = clean.execute();
-      assertThat(deletedDrafts).isEqualTo(0);
+        /* Re-execute the cleanup and make sure nothing's changed */
+        deletedDrafts = clean.execute();
+        assertThat(deletedDrafts).isEqualTo(0);
+      }
       assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(2);
       assertThat(usersRepo.exactRef(ref1.getName())).isNotNull();
       assertThat(usersRepo.exactRef(ref2.getName())).isNull();
@@ -103,13 +103,13 @@
 
       /* Increase the cleanup percentage */
       cleanupPercentage = 70;
-      clean =
+      try (DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {})) {
 
-      deletedDrafts = clean.execute();
-      assertThat(deletedDrafts).isEqualTo(1);
-
+        int deletedDrafts = clean.execute();
+        assertThat(deletedDrafts).isEqualTo(1);
+      }
       /* Now ref3 is deleted */
       assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(1);
       assertThat(usersRepo.exactRef(ref1.getName())).isNotNull();
@@ -141,11 +141,12 @@
       assertThat(usersRepo.getRefDatabase().getRefs().size())
           .isEqualTo(goodRefs.size() + badRefs.size());
 
-      DeleteZombieCommentsRefs clean =
+      try (DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
-      int deletedDrafts = clean.execute();
-      assertThat(deletedDrafts).isEqualTo(5001);
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {})) {
+        int deletedDrafts = clean.execute();
+        assertThat(deletedDrafts).isEqualTo(5001);
+      }
 
       assertThat(
               usersRepo.getRefDatabase().getRefs().stream()
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 841d7c0..c96f3a1 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -142,7 +142,7 @@
               return GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid)
                   .getLoadedGroup()
                   .map(InternalGroup::getName)
-                  .orElse("Group " + uuid);
+                  .orElseGet(() -> "Group " + uuid);
             } catch (IOException | ConfigInvalidException e) {
               return "Group " + uuid;
             }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 8cb4974..53431d1 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -158,7 +158,7 @@
   public void tolerateNullValuesForInsertion() {
     Project.NameKey project = Project.nameKey("project");
     ChangeData cd =
-        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null);
     assertThat(ChangeField.ADDED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
@@ -166,7 +166,7 @@
   public void tolerateNullValuesForDeletion() {
     Project.NameKey project = Project.nameKey("project");
     ChangeData cd =
-        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null);
     assertThat(ChangeField.DELETED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null)))
         .isTrue();
   }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 26e9e54..c65e552 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.OrSource;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.Set;
@@ -46,6 +47,7 @@
 
 public class ChangeIndexRewriterTest {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
+  private static final int MAX_INDEX_QUERY_TERMS = 4;
 
   private FakeChangeIndex index;
   private ChangeIndexCollection indexes;
@@ -58,7 +60,9 @@
     indexes = new ChangeIndexCollection();
     indexes.setSearchIndex(index);
     queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.builder().maxTerms(3).build());
+    rewrite =
+        new ChangeIndexRewriter(
+            indexes, IndexConfig.builder().maxTerms(MAX_INDEX_QUERY_TERMS).build());
   }
 
   @Test
@@ -71,7 +75,7 @@
   public void nonIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
             query(
@@ -90,10 +94,24 @@
   }
 
   @Test
+  public void indexedOrSourceSubexpressions() throws Exception {
+    Predicate<ChangeData> in = parse("(file:a bar:b) OR (file:c bar:d)");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(OrSource.class);
+    assertThat(out.getChildCount()).isEqualTo(2);
+    assertThat(out.getChild(0).getChildren())
+        .containsExactly(query(parse("file:a")), parse("bar:b"))
+        .inOrder();
+    assertThat(out.getChild(1).getChildren())
+        .containsExactly(query(parse("file:c")), parse("bar:d"))
+        .inOrder();
+  }
+
+  @Test
   public void nonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("foo:a OR foo:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
             query(
@@ -106,10 +124,26 @@
   }
 
   @Test
+  public void nonIndexOrSourcePredicates() throws Exception {
+    Predicate<ChangeData> in = parse("baz:a OR baz:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(OrSource.class);
+    assertThat(out.getChildren()).containsExactly(parse("baz:a"), parse("baz:b")).inOrder();
+  }
+
+  @Test
+  public void nonIndexAndSourcePredicates() throws Exception {
+    Predicate<ChangeData> in = parse("baz:a baz:b");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
+    assertThat(out.getChildren()).containsExactly(parse("baz:a"), parse("baz:b")).inOrder();
+  }
+
+  @Test
   public void oneIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren()).containsExactly(query(parse("file:b")), parse("foo:a")).inOrder();
   }
 
@@ -167,7 +201,7 @@
   public void indexAndNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("status:new bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(query(andCardinal(parse("status:new"), parse("file:a"))), parse("bar:p"))
         .inOrder();
@@ -239,12 +273,12 @@
 
   @Test
   public void tooManyTerms() throws Exception {
-    String q = "file:a OR file:b OR file:c";
+    String q = "file:a OR file:b OR file:c OR file:d";
     Predicate<ChangeData> in = parse(q);
     assertEquals(query(in), rewrite(in));
 
     QueryParseException thrown =
-        assertThrows(QueryParseException.class, () -> rewrite(parse(q + " OR file:d")));
+        assertThrows(QueryParseException.class, () -> rewrite(parse(q + " OR file:e")));
     assertThat(thrown).hasMessageThat().contains("too many terms in query");
   }
 
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 6e3514e..1a51c00 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -110,6 +110,11 @@
   }
 
   @Override
+  public int numDocs() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
       throws QueryParseException {
     return new FakeChangeIndex.Source(p);
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 90a9b9d..d816719 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -14,16 +14,48 @@
 
 package com.google.gerrit.server.index.change;
 
+import com.google.gerrit.index.query.FieldBundle;
 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.index.query.ResultSet;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Ignore;
 
 @Ignore
 public class FakeQueryBuilder extends ChangeQueryBuilder {
+  public static class FakeNonIndexSourcePredicate extends OperatorPredicate<ChangeData>
+      implements ChangeDataSource {
+    private static final String operator = "baz";
+
+    public FakeNonIndexSourcePredicate(String value) {
+      super(operator, value);
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() {
+      return null;
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() {
+      return null;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 0;
+    }
+
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+  }
+
   FakeQueryBuilder(ChangeIndexCollection indexes) {
     super(
         new QueryBuilder.Definition<>(FakeQueryBuilder.class),
@@ -64,6 +96,11 @@
   }
 
   @Operator
+  public Predicate<ChangeData> baz(String value) {
+    return new FakeNonIndexSourcePredicate(value);
+  }
+
+  @Operator
   public Predicate<ChangeData> foo(String value) {
     return predicate("foo", value);
   }
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 1f0da16..8cdf16a 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,6 +24,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
@@ -51,7 +52,8 @@
     testPerformanceLogger =
         new PerformanceLogger() {
           @Override
-          public void log(String operation, long durationMs, Metadata metadata) {
+          public void logNanos(
+              String operation, long durationNanos, Instant endTime, Metadata metadata) {
             // do nothing
           }
         };
diff --git a/javatests/com/google/gerrit/server/logging/MetadataTest.java b/javatests/com/google/gerrit/server/logging/MetadataTest.java
index f9ae2c1..d7981f8 100644
--- a/javatests/com/google/gerrit/server/logging/MetadataTest.java
+++ b/javatests/com/google/gerrit/server/logging/MetadataTest.java
@@ -23,15 +23,14 @@
   @Test
   public void stringForLoggingOmitsEmptyOptionalValuesAndReformatsOptionalValuesThatArePresent() {
     Metadata metadata = Metadata.builder().accountId(1000001).branchName("refs/heads/foo").build();
-    assertThat(metadata.toStringForLoggingLazy().evaluate())
-        .isEqualTo("Metadata{accountId=1000001, branchName=refs/heads/foo, pluginMetadata=[]}");
+    assertThat(metadata.toStringForLogging())
+        .isEqualTo("Metadata{accountId=1000001, branchName=refs/heads/foo}");
   }
 
   @Test
   public void
       stringForLoggingOmitsEmptyOptionalValuesAndReformatsOptionalValuesThatArePresentNoFieldsSet() {
     Metadata metadata = Metadata.builder().build();
-    assertThat(metadata.toStringForLoggingLazy().evaluate())
-        .isEqualTo("Metadata{pluginMetadata=[]}");
+    assertThat(metadata.toStringForLogging()).isEqualTo("Metadata{}");
   }
 }
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index 512a1b1..4b3c658 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -32,6 +32,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import java.time.Instant;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
@@ -364,7 +365,7 @@
     private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
+    public void logNanos(String operation, long durationNanos, Instant endTime, Metadata metadata) {
       logEntries.add(PerformanceLogEntry.create(operation, metadata));
     }
 
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 629b0cc..8a8db44 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider.DefaultUserAddressGenFactory;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.util.Arrays;
 import java.util.List;
@@ -47,7 +48,9 @@
   }
 
   private FromAddressGenerator create() {
-    return new FromAddressGeneratorProvider(config, "Anonymous Coward", ident, accountCache).get();
+    return new FromAddressGeneratorProvider(
+            config, "Anonymous Coward", ident, accountCache, new DefaultUserAddressGenFactory())
+        .get();
   }
 
   private void setFrom(String newFrom) {
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 5a89584..dabb944 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -24,7 +24,6 @@
 import java.sql.Timestamp;
 import java.time.ZonedDateTime;
 import java.util.TimeZone;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.After;
 import org.junit.Before;
@@ -53,12 +52,6 @@
    */
   private static final long MID_DST_MS = 1383466224175L;
 
-  /**
-   * Ambiguous string representation of {@link #MID_DST_MS} that was actually stored in NoteDb for
-   * this comment.
-   */
-  private static final String MID_DST_STR = "Nov 3, 2013 1:10:24 AM";
-
   private TimeZone systemTimeZone;
   private Gson legacyGson;
   private Gson gson;
@@ -78,32 +71,20 @@
     TimeZone.setDefault(systemTimeZone);
   }
 
-  @Test
-  public void legacyGsonBehavesAsExpectedDuringDstTransition() {
-    long oneHourMs = TimeUnit.HOURS.toMillis(1);
-
-    String beforeJson = "\"Nov 3, 2013 12:10:24 AM\"";
-    Timestamp beforeTs = new Timestamp(MID_DST_MS - oneHourMs);
-    assertThat(legacyGson.toJson(beforeTs)).isEqualTo(beforeJson);
-
-    String ambiguousJson = '"' + MID_DST_STR + '"';
-    Timestamp duringTs = new Timestamp(MID_DST_MS);
-    assertThat(legacyGson.toJson(duringTs)).isEqualTo(ambiguousJson);
-
-    Timestamp afterTs = new Timestamp(MID_DST_MS + oneHourMs);
-    assertThat(legacyGson.toJson(afterTs)).isEqualTo(ambiguousJson);
-
-    Timestamp beforeTsTruncated = new Timestamp(beforeTs.getTime() / 1000 * 1000);
-    assertThat(legacyGson.fromJson(beforeJson, Timestamp.class)).isEqualTo(beforeTsTruncated);
-
-    // Gson just picks one, and it happens to be the one after the PST transition.
-    Timestamp afterTsTruncated = new Timestamp(afterTs.getTime() / 1000 * 1000);
-    assertThat(legacyGson.fromJson(ambiguousJson, Timestamp.class)).isEqualTo(afterTsTruncated);
+  private String normalizeWhitespaces(String input) {
+    // There is a known difference between different JDK versions. Common Locale Data Repository
+    // (CLDR) version 42 replaces ASCII spaces (U+0020) with NNBSP (Narrow No-Break Space, U+202F)
+    // in some places within the date time. The change was made in CLDR 42, which  JDK 20 and newer
+    // use: https://bugs.openjdk.org/browse/JDK-8284840.
+    // For test purposes, we will make whitespaces consistent, so they can be compared directly with
+    // an expected output in all JDK version.
+    return input.replace("\u202F", " ");
   }
 
   @Test
   public void legacyAdapterViaZonedDateTime() {
-    assertThat(legacyGson.toJson(NON_DST_TS)).isEqualTo("\"Feb 7, 2017 2:20:30 AM\"");
+    assertThat(normalizeWhitespaces(legacyGson.toJson(NON_DST_TS)))
+        .isEqualTo("\"Feb 7, 2017, 2:20:30 AM\"");
   }
 
   @Test
@@ -117,7 +98,15 @@
   @Test
   public void newAdapterCanParseOutputOfLegacyAdapter() {
     String legacyJson = legacyGson.toJson(NON_DST_TS);
-    assertThat(legacyJson).isEqualTo("\"Feb 7, 2017 2:20:30 AM\"");
+    assertThat(normalizeWhitespaces(legacyJson)).isEqualTo("\"Feb 7, 2017, 2:20:30 AM\"");
+    assertThat(gson.fromJson(legacyJson, Timestamp.class))
+        .isEqualTo(new Timestamp(NON_DST_TS.getTime() / 1000 * 1000));
+  }
+
+  @Test
+  public void newAdapterCanParseOutputOfLegacyAdapterFromOldJDK() {
+    // The old JDK8 formatted the date time without a comma after the year.
+    String legacyJson = "\"Feb 7, 2017 2:20:30 AM\"";
     assertThat(gson.fromJson(legacyJson, Timestamp.class))
         .isEqualTo(new Timestamp(NON_DST_TS.getTime() / 1000 * 1000));
   }
diff --git a/javatests/com/google/gerrit/server/plugins/PluginOrderComparatorTest.java b/javatests/com/google/gerrit/server/plugins/PluginOrderComparatorTest.java
new file mode 100644
index 0000000..4f7402a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/plugins/PluginOrderComparatorTest.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Sets;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+import java.util.jar.Manifest;
+import org.junit.Test;
+
+public class PluginOrderComparatorTest {
+  private static final String API_MODULE = "Gerrit-ApiModule: com.google.gerrit.UnitTest";
+
+  private static final Path FIRST_PLUGIN_PATH = Paths.get("01-first.jar");
+  private static final Path SECOND_PLUGIN_PATH = Paths.get("20-second.jar");
+  private static final Path THIRD_PLUGIN_PATH = Paths.get("30-third.jar");
+  private static final Path LAST_PLUGIN_PATH = Paths.get("99-last.jar");
+
+  private static final Map.Entry<String, Path> FIRST_ENTRY = Map.entry("first", FIRST_PLUGIN_PATH);
+  private static final Map.Entry<String, Path> SECOND_ENTRY =
+      Map.entry("second", SECOND_PLUGIN_PATH);
+  private static final Map.Entry<String, Path> THIRD_ENTRY = Map.entry("third", THIRD_PLUGIN_PATH);
+  private static final Map.Entry<String, Path> LAST_ENTRY = Map.entry("last", LAST_PLUGIN_PATH);
+
+  private static final Manifest EMPTY_MANIFEST = newManifest("");
+  private static final Manifest API_MODULE_MANIFEST = newManifest(API_MODULE);
+
+  @Test
+  public void shouldOrderPluginsBasedOnFileName() {
+    PluginOrderComparator comparator =
+        new PluginOrderComparator(ImmutableList.of(), pluginPath -> EMPTY_MANIFEST);
+
+    assertOrder(comparator, List.of(FIRST_ENTRY, LAST_ENTRY), List.of(FIRST_ENTRY, LAST_ENTRY));
+  }
+
+  @Test
+  public void shouldReturnPluginWithApiModuleFirst() {
+    // return empty manifest for the first plugin and manifest with ApiModule for the last
+    PluginOrderComparator.ManifestLoader loader = customLoader(EMPTY_MANIFEST, API_MODULE_MANIFEST);
+
+    PluginOrderComparator comparator = new PluginOrderComparator(ImmutableList.of(), loader);
+
+    assertOrder(comparator, List.of(FIRST_ENTRY, LAST_ENTRY), List.of(LAST_ENTRY, FIRST_ENTRY));
+  }
+
+  @Test
+  public void shouldUseOrderFromOverrideListBasedOnFileName() {
+    ImmutableList<String> pluginOrderOverrides = ImmutableList.of("last", "first");
+
+    PluginOrderComparator comparator =
+        new PluginOrderComparator(pluginOrderOverrides, pluginPath -> EMPTY_MANIFEST);
+
+    assertOrder(comparator, List.of(FIRST_ENTRY, LAST_ENTRY), List.of(LAST_ENTRY, FIRST_ENTRY));
+  }
+
+  @Test
+  public void shouldCombineOrderOverridesAndNaturalFileNaming() {
+    ImmutableList<String> pluginOrderOverrides = ImmutableList.of("third", "second");
+
+    PluginOrderComparator comparator =
+        new PluginOrderComparator(pluginOrderOverrides, pluginPath -> EMPTY_MANIFEST);
+
+    assertOrder(
+        comparator,
+        List.of(FIRST_ENTRY, SECOND_ENTRY, THIRD_ENTRY, LAST_ENTRY),
+        List.of(THIRD_ENTRY, SECOND_ENTRY, FIRST_ENTRY, LAST_ENTRY));
+  }
+
+  @Test
+  public void shouldReturnApiModuleFirstWithOrderOverrides() {
+    ImmutableList<String> pluginOrderOverrides = ImmutableList.of("third", "second");
+    PluginOrderComparator.ManifestLoader loader =
+        pluginPath -> {
+          if (pluginPath.equals(LAST_PLUGIN_PATH)) {
+            return API_MODULE_MANIFEST;
+          }
+          return EMPTY_MANIFEST;
+        };
+
+    PluginOrderComparator comparator = new PluginOrderComparator(pluginOrderOverrides, loader);
+
+    assertOrder(
+        comparator,
+        List.of(FIRST_ENTRY, SECOND_ENTRY, THIRD_ENTRY, LAST_ENTRY),
+        List.of(LAST_ENTRY, THIRD_ENTRY, SECOND_ENTRY, FIRST_ENTRY));
+  }
+
+  private void assertOrder(
+      PluginOrderComparator comparator,
+      List<Map.Entry<String, Path>> input,
+      List<Map.Entry<String, Path>> expected) {
+    TreeSet<Map.Entry<String, Path>> actual = Sets.newTreeSet(comparator);
+    actual.addAll(input);
+
+    assertThat(List.copyOf(actual)).isEqualTo(expected);
+  }
+
+  private static Manifest newManifest(String content) {
+    String withEmptyLine = content + "\n";
+    try {
+      Manifest manifest = new Manifest();
+      manifest.read(new ByteArrayInputStream(withEmptyLine.getBytes(UTF_8)));
+      return manifest;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private PluginOrderComparator.ManifestLoader customLoader(
+      Manifest firstManifest, Manifest secondManifest) {
+    return pluginPath -> {
+      if (pluginPath.equals(FIRST_PLUGIN_PATH)) {
+        return firstManifest;
+      }
+      if (pluginPath.equals(LAST_PLUGIN_PATH)) {
+        return secondManifest;
+      }
+      throw new IllegalArgumentException("unsupported path: " + pluginPath);
+    };
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 08aa670..e651302 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -40,6 +40,7 @@
 
   @Before
   public void setup() {
+    @SuppressWarnings("deprecation")
     LabelType codeReview =
         LabelType.builder(
                 "Code-Review",
@@ -50,6 +51,7 @@
             .setFunction(LabelFunction.MAX_WITH_BLOCK)
             .build();
 
+    @SuppressWarnings("deprecation")
     LabelType verified =
         LabelType.builder(
                 "Verified",
@@ -60,6 +62,7 @@
             .setFunction(LabelFunction.MAX_NO_BLOCK)
             .build();
 
+    @SuppressWarnings("deprecation")
     LabelType codeStyle =
         LabelType.builder(
                 "Code-Style",
@@ -70,6 +73,7 @@
             .setFunction(LabelFunction.ANY_WITH_BLOCK)
             .build();
 
+    @SuppressWarnings("deprecation")
     LabelType ignoreSelfApprovalLabel =
         LabelType.builder(
                 "ISA-Label",
@@ -333,6 +337,29 @@
   }
 
   @Test
+  public void customSubmitRule_withLabels_withStatusOk() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~PrologRule",
+            Status.OK,
+            Arrays.asList(
+                createLabel("custom-need-label-1", Label.Status.NEED),
+                createLabel("custom-pass-label-2", Label.Status.OK),
+                createLabel("custom-may-label-3", Label.Status.MAY)));
+
+    ImmutableList<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId, false);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "custom-pass-label-2",
+        /* submitExpression= */ "label:custom-pass-label-2=gerrit~PrologRule",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
   public void customSubmitRule_withMixOfPassingAndFailingLabels() {
     SubmitRecord submitRecord =
         createSubmitRecord(
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 6258b18..1a546fa 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1552,6 +1552,12 @@
   }
 
   @Test
+  public void cannotUseUsersArgWithLabel() throws Exception {
+    assertFailingQuery(
+        "label:Code-Review=MAX,users=human_reviewers", "Cannot use the 'users' argument in search");
+  }
+
+  @Test
   public void byLabelMulti() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     repo = createAndOpenProject(project);
@@ -3576,7 +3582,7 @@
     getChangeApi(change).addReviewer(anotherUser.toString());
 
     assertQuery("reviewer:self", change);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
+    assertThat(indexer.reindexIfStale(project, change.getId())).isFalse();
 
     // Remove reviewer behind index's back.
     ChangeUpdate update = newUpdate(change);
@@ -3585,7 +3591,7 @@
 
     // Index is stale.
     assertQuery("reviewer:self", change);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
+    assertThat(indexer.reindexIfStale(project, change.getId())).isTrue();
     assertQuery("reviewer:self");
 
     // Index is not stale when a draft comment exists
@@ -3594,7 +3600,7 @@
     in.message = "nit: trailing whitespace";
     in.path = Patch.COMMIT_MSG;
     getChangeApi(change).current().createDraft(in);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
+    assertThat(indexer.reindexIfStale(project, change.getId())).isFalse();
   }
 
   @Test
@@ -3627,6 +3633,24 @@
   }
 
   @Test
+  public void watched_projectWatchThatUsesIsWatchedIsIgnored() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    createProject(project);
+    insert(project, newChangeWithStatus(project, Change.Status.NEW));
+
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = "repo";
+    pwi.filter = "is:watched";
+    pwi.notifyNewChanges = true;
+    projectsToWatch.add(pwi);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    resetUser();
+
+    assertQuery("is:watched");
+  }
+
+  @Test
   public void trackingid() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     repo = createAndOpenProject(project);
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 57a3c4b..7854ce4 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -14,7 +14,6 @@
     visibility = ["//visibility:public"],
     runtime_deps = [
         "//java/com/google/gerrit/lucene",
-        "//prolog:gerrit-prolog-common",
     ],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index 0ce00eb..f954a57 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -34,8 +34,6 @@
 
 @RunWith(MockitoJUnitRunner.class)
 public class ChangeDataTest {
-  private static final String GERRIT_SERVER_ID = UUID.randomUUID().toString();
-
   @Mock private ChangeNotes changeNotesMock;
 
   @Test
@@ -55,7 +53,7 @@
   @Test
   public void getChangeVirtualIdUsingAlgorithm() throws Exception {
     Project.NameKey project = Project.nameKey("project");
-    final int encodedChangeNum = 12345678;
+    final Change.Id encodedChangeNum = Change.id(12345678);
 
     when(changeNotesMock.getServerId()).thenReturn(UUID.randomUUID().toString());
 
@@ -65,11 +63,10 @@
             Change.id(1),
             1,
             ObjectId.zeroId(),
-            GERRIT_SERVER_ID,
             (s, c) -> encodedChangeNum,
             changeNotesMock);
 
-    assertThat(cd.getVirtualId().get()).isEqualTo(encodedChangeNum);
+    assertThat(cd.virtualId().get()).isEqualTo(encodedChangeNum.get());
   }
 
   private static PatchSet newPatchSet(Change.Id changeId, int num) {
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index c7f4f64..d00cc45 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -26,6 +26,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -90,11 +92,49 @@
     @SuppressWarnings("unused")
     var unused = newQuery("status:new").withLimit(5).get();
 
+    // Since the limit of the query (i.e. 5) is more than the total number of changes (i.e. 4),
+    // only 1 index search is expected.
     assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
   }
 
   @Test
   @UseClockStep
+  public void queryRightNumberOfTimes() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    TestRepository<Repository> repo = createAndOpenProject(project);
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+
+    // create 1 visible change
+    Change visibleChange1 = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
+
+    // create 4 private changes
+    Change invisibleChange2 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange3 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange4 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange5 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange3.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange4.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange5.getKey().get()).setPrivate(true, null);
+
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+    idx.resetQueryCount();
+    List<ChangeInfo> queryResult = newQuery("status:new").withLimit(2).get();
+    assertThat(queryResult).hasSize(1);
+    assertThat(queryResult.get(0).changeId).isEqualTo(visibleChange1.getKey().get());
+
+    // Since the limit of the query (i.e. 2), 2 index searches are expected in fact:
+    // 1: The first query will return invisibleChange5, invisibleChange4 and invisibleChange3,
+    // 2: Another query is needed to back-fill the limit requested by the user.
+    // even if one result in the second query is skipped because it is not visible,
+    // there are no more results to query.
+    assertThatSearchQueryWasPaginated(idx.getQueryCount(), 2);
+  }
+
+  @Test
+  @UseClockStep
   public void noLimitQueryPaginates() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
 
@@ -139,39 +179,7 @@
 
   @Test
   @UseClockStep
-  public void invisibleChangesNotPaginatedWithNonePaginationType() throws Exception {
-    assumeTrue(PaginationType.NONE == getCurrentPaginationType());
-    AbstractFakeIndex<?, ?, ?> idx = setupRepoWithFourChanges();
-    final int LIMIT = 3;
-
-    projectOperations
-        .project(allProjectsName)
-        .forUpdate()
-        .removeAllAccessSections()
-        .add(allow(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
-        .update();
-
-    // Set queryLimit to 3
-    projectOperations
-        .project(allProjects)
-        .forUpdate()
-        .add(allowCapability(QUERY_LIMIT).group(ANONYMOUS_USERS).range(0, LIMIT))
-        .update();
-
-    @SuppressWarnings("unused")
-    var unused = requestContext.setContext(anonymousUserProvider::get);
-
-    List<ChangeInfo> result = newQuery("status:new").withLimit(LIMIT).get();
-    assertThat(result.size()).isEqualTo(0);
-    assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
-    assertThat(idx.getResultsSizes().get(0)).isEqualTo(LIMIT + 1);
-  }
-
-  @Test
-  @UseClockStep
   public void invisibleChangesPaginatedWithPagination() throws Exception {
-    assumeFalse(PaginationType.NONE == getCurrentPaginationType());
-
     AbstractFakeIndex<?, ?, ?> idx = setupRepoWithFourChanges();
     final int LIMIT = 3;
 
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 987a87e..82c9065 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -109,4 +110,28 @@
     Change[] expected = new Change[] {change6, change5, change4, change3, change2, change1};
     assertQuery(newQuery("project:repo").withNoLimit(), expected);
   }
+
+  @Test
+  public void skipChangesNotVisible() throws Exception {
+    // create 1 new change on a repo
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change visibleChange = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
+    Change[] expected = new Change[] {visibleChange};
+
+    // pagination does not need to restart the datasource, the request is fulfilled
+    assertQuery(newQuery("status:new").withLimit(1), expected);
+
+    // create 2 new private changes
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+
+    Change invisibleChange1 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange2 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    gApi.changes().id(invisibleChange1.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
+
+    // pagination should back-fill when the results skipped because of the visibility
+    assertQuery(newQuery("status:new").withLimit(1), expected);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
index bf224f0..01bf941 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
@@ -52,14 +53,15 @@
   public void internalQueriesDoNotPaginateWithNonePaginationType() throws Exception {
     assumeTrue(PaginationType.NONE == getCurrentPaginationType());
 
-    final int GROUPS_CREATED_SIZE = 2;
-    List<GroupInfo> groupsCreated = new ArrayList<>();
-    for (int i = 0; i < GROUPS_CREATED_SIZE; i++) {
-      groupsCreated.add(createGroupThatIsVisibleToAll(name("group-" + i)));
+    List<GroupInfo> groupsVisibleToAll = new ArrayList<>();
+    groupsVisibleToAll.add(gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).get());
+
+    for (int i = 0; i < 2; i++) {
+      groupsVisibleToAll.add(createGroupThatIsVisibleToAll(name("group-" + i)));
     }
 
-    List<GroupInfo> result = assertQuery(newQuery("is:visibletoall"), groupsCreated);
-    assertThat(result.size()).isEqualTo(GROUPS_CREATED_SIZE);
+    List<GroupInfo> result = assertQuery(newQuery("is:visibletoall"), groupsVisibleToAll);
+    assertThat(result.size()).isEqualTo(groupsVisibleToAll.size());
     assertThat(result.get(result.size() - 1)._moreGroups).isNull();
     assertThatSearchQueryWasNotPaginated();
   }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 572e7af..d8339e7 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -239,13 +240,14 @@
 
   @Test
   public void byIsVisibleToAll() throws Exception {
-    assertQuery("is:visibletoall");
+    GroupInfo serviceUsersGroupInfo = gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).get();
+    assertQuery("is:visibletoall", serviceUsersGroupInfo);
 
     GroupInfo groupThatIsVisibleToAll =
         createGroupThatIsVisibleToAll(name("group-that-is-visible-to-all"));
     createGroup(name("group"));
 
-    assertQuery("is:visibletoall", groupThatIsVisibleToAll);
+    assertQuery("is:visibletoall", groupThatIsVisibleToAll, serviceUsersGroupInfo);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java b/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java
new file mode 100644
index 0000000..9a926a2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TagSorterTest {
+  private static final String revision = "dfdd715e31db256dfba48239f83f9b8da4bc243f";
+  private static final boolean canDelete = true;
+  private static final List<WebLinkInfo> webLinks = new ArrayList<>();
+  private static final TagSorter tagSorter = new TagSorter();
+  private List<TagInfo> tags;
+
+  @Before
+  public void initializeTags() {
+    tags = createTags();
+  }
+
+  @Test
+  public void testSortTagsByRef() {
+    tagSorter.sort(ListTagSortOption.REF, tags, false);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v1.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v4.0");
+  }
+
+  @Test
+  public void testSortTagsByCreationTime() {
+    tagSorter.sort(ListTagSortOption.CREATION_TIME, tags, false);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v1.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v4.0");
+  }
+
+  @Test
+  public void testSortTagsByCreationTimeDescendingOrder() {
+    tagSorter.sort(ListTagSortOption.CREATION_TIME, tags, true);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v4.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v1.0");
+  }
+
+  private List<TagInfo> createTags() {
+    Instant t1 = Instant.now();
+    Instant t2 = t1.minusSeconds(10);
+    Instant t3 = t1.minusSeconds(1);
+
+    List<TagInfo> tags = new ArrayList<>();
+    tags.add(new TagInfo("refs/tags/v1.0", revision, canDelete, webLinks, t1));
+    tags.add(new TagInfo("refs/tags/v2.0", revision, canDelete, webLinks, t2));
+    tags.add(new TagInfo("refs/tags/v3.0", revision, canDelete, webLinks, t3));
+    tags.add(new TagInfo("refs/tags/v4.0", revision, canDelete, webLinks, (Instant) null));
+
+    return tags;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index 6c79c43..089ceea 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultSubmitRequirements;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
@@ -89,11 +90,13 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
-            .serviceUsersGroup(batchUsersGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -139,6 +142,7 @@
             .addBooleanProjectConfig(
                 BooleanProjectConfig.REJECT_EMPTY_COMMIT, InheritableBoolean.TRUE)
             .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
@@ -158,6 +162,26 @@
   }
 
   @Test
+  public void createAllProjectsWithoutInitializingDefaultSubmitRequirements() throws Exception {
+    GroupReference adminsGroup = createGroupReference("Administrators");
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder()
+            .administratorsGroup(adminsGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
+            .initDefaultSubmitRequirements(false)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getAllProjectsWithoutDefaultSubmitRequirements());
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+
+  @Test
   public void createAllProjectsOnlyInitializingProjectDescription() throws Exception {
     String description = "a project.config with just a project description";
     AllProjectsInput allProjectsInput =
@@ -165,6 +189,7 @@
             .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
             .projectDescription(description)
             .initDefaultAcls(false)
+            .initDefaultSubmitRequirements(false)
             .build();
     allProjectsCreator.create(allProjectsInput);
 
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 345681d..a602ee9 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -12,11 +12,13 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
+        "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib/guice",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index f7a2afa..79a688c 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -22,6 +22,9 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import com.github.rholder.retry.Attempt;
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.RetryListener;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -37,7 +40,10 @@
 import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.Sequences;
@@ -53,6 +59,7 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
@@ -61,12 +68,17 @@
 import com.google.inject.name.Named;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -107,6 +119,8 @@
   @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private InternalUser.Factory internalUserFactory;
   @Inject private AbandonOp.Factory abandonOpFactory;
+  @Inject @GerritPersonIdent private PersonIdent serverIdent;
+  @Inject private RetryHelper retryHelper;
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
@@ -566,6 +580,245 @@
     assertThat(metaCommit.getParent(0)).isEqualTo(oldHead);
   }
 
+  @Test
+  public void lockFailureOnConcurrentUpdate() throws Exception {
+    Change.Id changeId = createChange();
+    ObjectId metaId = getMetaId(changeId);
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on the first attempt.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            try (RevWalk rw = new RevWalk(repo.getRepository())) {
+              RevCommit old = rw.parseCommit(metaId);
+              RevCommit commit =
+                  repo.commit()
+                      .parent(old)
+                      .author(serverIdent)
+                      .committer(serverIdent)
+                      .setTopLevelTree(old.getTree())
+                      .message("Concurrent Update\n\nPatch-Set: 1")
+                      .create();
+              RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId));
+              ru.setExpectedOldObjectId(metaId);
+              ru.setNewObjectId(commit);
+              ru.update();
+              RefUpdateUtil.checkResult(ru);
+              doneBackgroundUpdate.set(true);
+            } catch (Exception e) {
+              // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later
+              // when doneBackgroundUpdate is checked.
+            }
+            return bru;
+          }
+        };
+
+    // Do a batch update, expect that it fails with LOCK_FAILURE due to the concurrent update.
+    assertThat(doneBackgroundUpdate.get()).isFalse();
+    UpdateException exception =
+        assertThrows(
+            UpdateException.class,
+            () -> {
+              try (BatchUpdate bu =
+                  batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+                bu.addOp(changeId, abandonOpFactory.create(null, "abandon"));
+                bu.execute(listener);
+              }
+            });
+    assertThat(exception).hasCauseThat().isInstanceOf(LockFailureException.class);
+    assertThat(doneBackgroundUpdate.get()).isTrue();
+
+    // Check that the change was not updated.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+  }
+
+  @Test
+  public void useRetryHelperToRetryOnLockFailure() throws Exception {
+    Change.Id changeId = createChange();
+    ObjectId metaId = getMetaId(changeId);
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on the first attempt.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            if (!doneBackgroundUpdate.getAndSet(true)) {
+              try (RevWalk rw = new RevWalk(repo.getRepository())) {
+                RevCommit old = rw.parseCommit(metaId);
+                RevCommit commit =
+                    repo.commit()
+                        .parent(old)
+                        .author(serverIdent)
+                        .committer(serverIdent)
+                        .setTopLevelTree(old.getTree())
+                        .message("Concurrent Update\n\nPatch-Set: 1")
+                        .create();
+                RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId));
+                ru.setExpectedOldObjectId(metaId);
+                ru.setNewObjectId(commit);
+                ru.update();
+                RefUpdateUtil.checkResult(ru);
+              } catch (Exception e) {
+                // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later
+                // when doneBackgroundUpdate is checked.
+              }
+            }
+            return bru;
+          }
+        };
+
+    // Do a batch update, expect that it succeeds due to retrying despite the LOCK_FAILURE on the
+    // first attempt.
+    assertThat(doneBackgroundUpdate.get()).isFalse();
+
+    @SuppressWarnings("unused")
+    var unused =
+        retryHelper
+            .changeUpdate(
+                "batchUpdate",
+                updateFactory -> {
+                  try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
+                    bu.addOp(changeId, abandonOpFactory.create(null, "abandon"));
+                    bu.execute(listener);
+                  }
+                  return null;
+                })
+            .call();
+
+    // Check that the concurrent update was done.
+    assertThat(doneBackgroundUpdate.get()).isTrue();
+
+    // Check that the BatchUpdate updated the change.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.ABANDONED);
+  }
+
+  @Test
+  public void noRetryingOnOuterLevelIfRetryingWasAlreadyDoneOnInnerLevel() throws Exception {
+    Change.Id changeId = createChange();
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean backgroundFailure = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on all attempts.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            try (RevWalk rw = new RevWalk(repo.getRepository())) {
+              String changeMetaRef = RefNames.changeMetaRef(changeId);
+              ObjectId metaId = repo.getRepository().exactRef(changeMetaRef).getObjectId();
+              RevCommit old = rw.parseCommit(metaId);
+              RevCommit commit =
+                  repo.commit()
+                      .parent(old)
+                      .author(serverIdent)
+                      .committer(serverIdent)
+                      .setTopLevelTree(old.getTree())
+                      .message("Concurrent Update\n\nPatch-Set: 1")
+                      .create();
+              RefUpdate ru = repo.getRepository().updateRef(changeMetaRef);
+              ru.setExpectedOldObjectId(metaId);
+              ru.setNewObjectId(commit);
+              ru.update();
+              RefUpdateUtil.checkResult(ru);
+            } catch (Exception e) {
+              backgroundFailure.set(true);
+            }
+
+            return bru;
+          }
+        };
+
+    AtomicInteger innerRetryOnExceptionCounter = new AtomicInteger();
+    AtomicInteger outerRetryOnExceptionCounter = new AtomicInteger();
+    UpdateException exception =
+        assertThrows(
+            UpdateException.class,
+            () ->
+                // Outer level retrying. We expect that no retrying is happens here because retrying
+                // is already done on the inner level.
+                retryHelper
+                    .action(
+                        ActionType.CHANGE_UPDATE,
+                        "batchUpdate",
+                        () ->
+                            // Inner level retrying. We expect that retrying happens here.
+                            retryHelper
+                                .changeUpdate(
+                                    "batchUpdate",
+                                    updateFactory -> {
+                                      try (BatchUpdate bu =
+                                          updateFactory.create(
+                                              project, user.get(), TimeUtil.now())) {
+                                        bu.addOp(
+                                            changeId, abandonOpFactory.create(null, "abandon"));
+                                        bu.execute(listener);
+                                      }
+                                      return null;
+                                    })
+                                .listener(
+                                    new RetryListener() {
+                                      @Override
+                                      public <V> void onRetry(Attempt<V> attempt) {
+                                        if (attempt.hasException()) {
+                                          @SuppressWarnings("unused")
+                                          var unused =
+                                              innerRetryOnExceptionCounter.incrementAndGet();
+                                        }
+                                      }
+                                    })
+                                .call())
+                    // give it enough time to potentially retry multiple times when each retry also
+                    // does retrying
+                    .defaultTimeoutMultiplier(5)
+                    .listener(
+                        new RetryListener() {
+                          @Override
+                          public <V> void onRetry(Attempt<V> attempt) {
+                            if (attempt.hasException()) {
+                              @SuppressWarnings("unused")
+                              var unused = outerRetryOnExceptionCounter.incrementAndGet();
+                            }
+                          }
+                        })
+                    .call());
+    assertThat(backgroundFailure.get()).isFalse();
+
+    // Check that retrying was done on the inner level.
+    assertThat(innerRetryOnExceptionCounter.get()).isGreaterThan(1);
+
+    // Check that there was no retrying on the outer level since retrying was already done on the
+    // inner level.
+    // We expect 1 because RetryListener#onRetry is invoked before the rejection predicate and stop
+    // strategies are applied (i.e. before RetryHelper decides whether retrying should be done).
+    assertThat(outerRetryOnExceptionCounter.get()).isEqualTo(1);
+
+    assertThat(exception).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(exception.getCause()).hasCauseThat().isInstanceOf(UpdateException.class);
+    assertThat(exception.getCause().getCause())
+        .hasCauseThat()
+        .isInstanceOf(LockFailureException.class);
+
+    // Check that the change was not updated.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+  }
+
   private Change.Id createChange() throws Exception {
     Change.Id id = Change.id(sequences.nextChangeId());
     try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
diff --git a/javatests/com/google/gerrit/sshd/BUILD b/javatests/com/google/gerrit/sshd/BUILD
index 3e11ff2..44b9c62 100644
--- a/javatests/com/google/gerrit/sshd/BUILD
+++ b/javatests/com/google/gerrit/sshd/BUILD
@@ -4,8 +4,11 @@
     name = "sshd_tests",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/sshd",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/mina:sshd",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/sshd/SshUtilTest.java b/javatests/com/google/gerrit/sshd/SshUtilTest.java
new file mode 100644
index 0000000..1585bc3
--- /dev/null
+++ b/javatests/com/google/gerrit/sshd/SshUtilTest.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.AccountSshKey;
+import java.security.spec.InvalidKeySpecException;
+import org.junit.Test;
+
+public class SshUtilTest {
+  private static final Account.Id TEST_ACCOUNT_ID = Account.id(1);
+  private static final int TEST_SSHKEY_SEQUENCE = 1;
+  private static final String INVALID_ALGO = "invalid-algo";
+  private static final String VALID_OPENSSH_RSA_KEY =
+      "AAAAB3NzaC1yc2EAAAABIwAAAIEA0R66EoZ7hFp81w9sAJqu34UFyE+w36H/mobUqnT5Lns7PcTOJh3sgMJAlswX2lFAWqvF2gd2PRMpMhbfEU4iq2SfY8x+RDCJ4ZQWESln/587T41BlQjOXzu3W1bqgmtHnRCte3DjyWDvM/fucnUMSwOgP+FVEZCLTrk3thLMWsU=";
+  private static final Object VALID_SSH_RSA_ALGO = "ssh-rsa";
+
+  @Test
+  public void shouldFailParsingOpenSshKeyWithInvalidAlgo() {
+    String sshKeyWithInvalidAlgo = String.format("%s %s", INVALID_ALGO, VALID_OPENSSH_RSA_KEY);
+    AccountSshKey sshKey =
+        AccountSshKey.create(TEST_ACCOUNT_ID, TEST_SSHKEY_SEQUENCE, sshKeyWithInvalidAlgo);
+    assertThrows(InvalidKeySpecException.class, () -> SshUtil.parse(sshKey));
+  }
+
+  @Test
+  public void shouldParseSshKeyWithAlgoMatchingKey() {
+    String sshKeyWithValidKeyAlgo =
+        String.format("%s %s", VALID_SSH_RSA_ALGO, VALID_OPENSSH_RSA_KEY);
+    AccountSshKey sshKey =
+        AccountSshKey.create(TEST_ACCOUNT_ID, TEST_SSHKEY_SEQUENCE, sshKeyWithValidKeyAlgo);
+    assertThat(sshKey).isNotNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/testing/ConfigSuiteTest.java b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
index 1ec30da..d75bd23 100644
--- a/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
+++ b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
@@ -124,8 +124,8 @@
     new ConfigSuite(ConfigBasedTest.class).run(notifier);
     verify(configBasedTestListener, Mockito.times(6)).testExecuted(any(), any(), any());
 
-    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test1", "default", null);
-    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test2", "default", null);
+    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test1", "default", "default");
+    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test2", "default", "default");
     verify(configBasedTestListener, Mockito.times(1))
         .testExecuted("test1", "firstValue", "firstConfig");
     verify(configBasedTestListener, Mockito.times(1))
diff --git a/lib/BUILD b/lib/BUILD
index f9ece52..a0debc1 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -92,10 +92,7 @@
     name = "protobuf",
     data = ["//lib:LICENSE-protobuf"],
     visibility = ["//visibility:public"],
-    exports = [
-        "@com_google_protobuf//:protobuf_java",
-        "@com_google_protobuf//:protobuf_javalite",
-    ],
+    exports = ["@protobuf-java//jar"],
 )
 
 java_library(
diff --git a/lib/fonts/material-icons.woff2 b/lib/fonts/material-icons.woff2
index 11074da..4fd4a47 100644
--- a/lib/fonts/material-icons.woff2
+++ b/lib/fonts/material-icons.woff2
Binary files differ
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index 4105d85..d55a273 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -12,6 +12,7 @@
     srcs = [
         "@ui_npm//highlight.js",
         "@ui_npm//highlightjs-closure-templates",
+        "@ui_npm//highlightjs-epp",
         "@ui_npm//highlightjs-structured-text",
     ],
     config_file = "rollup.config.js",
diff --git a/lib/highlightjs/index.js b/lib/highlightjs/index.js
index c2d048d..811dec7 100644
--- a/lib/highlightjs/index.js
+++ b/lib/highlightjs/index.js
@@ -17,9 +17,11 @@
 
 import hljs from 'highlight.js';
 import soy from 'highlightjs-closure-templates';
+import epp from 'highlightjs-epp';
 import iecst from 'highlightjs-structured-text';
 
 hljs.registerLanguage('soy', soy);
+hljs.registerLanguage('epp', epp);
 hljs.registerLanguage('iecst', iecst);
 
 export default hljs;
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 6865340..11be929 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -11,6 +11,11 @@
 grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names
 
 cat << EOF > $TMP/want
+auto-common
+auto-factory
+auto-service-annotations
+auto-value
+auto-value-annotations
 cglib-3_2
 commons-io
 dropwizard-core
@@ -20,6 +25,7 @@
 flogger-google-extensions
 flogger-log4j-backend
 flogger-system-backend
+gson
 guava
 guava-testlib
 guice-assistedinject
@@ -43,6 +49,7 @@
 nekohtml
 objenesis
 openid-consumer
+protobuf-java
 soy
 sshd-mina
 sshd-osgi
diff --git a/modules/jgit b/modules/jgit
index c35deb6..3a7a9cb 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit c35deb6d8e0dfee8138a2e9eec7d384fd6ed162e
+Subproject commit 3a7a9cb0e89de263d0a5133949c9c9bed5141916
diff --git a/package.json b/package.json
index bfbf0c41..8d6206e 100644
--- a/package.json
+++ b/package.json
@@ -10,17 +10,17 @@
     "@typescript-eslint/parser": "^5.62.0"
   },
   "devDependencies": {
-    "@koa/cors": "^3.4.3",
+    "@koa/cors": "^5.0.0",
     "@types/page": "^1.11.9",
     "@typescript-eslint/eslint-plugin": "^5.62.0",
     "@web/dev-server": "^0.1.38",
     "@web/dev-server-esbuild": "^0.3.6",
-    "eslint": "^8.56.0",
+    "eslint": "^8.57.1",
     "eslint-config-google": "^0.14.0",
     "eslint-plugin-html": "^7.1.0",
-    "eslint-plugin-import": "^2.29.1",
+    "eslint-plugin-import": "^2.31.0",
     "eslint-plugin-jsdoc": "^44.2.7",
-    "eslint-plugin-lit": "^1.11.0",
+    "eslint-plugin-lit": "^1.15.0",
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-regex": "^1.10.0",
@@ -28,7 +28,7 @@
     "lit-analyzer": "^1.2.1",
     "npm-run-all": "^4.1.5",
     "prettier": "^2.8.8",
-    "rollup": "^2.79.1",
+    "rollup": "^2.79.2",
     "terser": "~5.8.0",
     "ts-lit-plugin": "^1.2.1",
     "typescript": "^4.9.5"
@@ -52,17 +52,18 @@
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
-    "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app"
+    "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app",
+    "gjf": "./tools/run_gjf.sh"
   },
   "repository": {
     "type": "git",
     "url": "https://gerrit.googlesource.com/gerrit"
   },
   "resolutions": {
-    "eslint": "^8.49.0",
+    "eslint": "^8.57.1",
     "@typescript-eslint/eslint-plugin": "^5.62.0",
     "@typescript-eslint/parser": "^5.62.0"
   },
   "author": "",
   "license": "Apache-2.0"
-}
+}
\ No newline at end of file
diff --git a/plugins/BUILD b/plugins/BUILD
index b9c51e3..c4acd92 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -20,7 +20,7 @@
 
 genrule2(
     name = "core",
-    srcs = ["//plugins/%s:%s.jar" % (n, n) for n in CORE_PLUGINS + CUSTOM_PLUGINS],
+    srcs = ["//plugins/%s.jar" % (n if ":" in n else "%s:%s" % (n, n)) for n in CORE_PLUGINS + CUSTOM_PLUGINS],
     outs = ["core.zip"],
     cmd = "mkdir -p $$TMP/WEB-INF/plugins;" +
           "for s in $(SRCS) ; do " +
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index d4f9247..e5e9ece 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit d4f9247d3efb6a0e461af701986235511d05b7e3
+Subproject commit e5e9ece112242397f000660c6cee8f5053ca5da5
diff --git a/plugins/delete-project b/plugins/delete-project
index ea78b4b..bd49c1b 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit ea78b4b817151f47f6e3aca7bf1e90f14518caa1
+Subproject commit bd49c1bf4212a166d1246774cb8c70d54ead31ba
diff --git a/plugins/hooks b/plugins/hooks
index f975f91..83d5aae 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit f975f914312b258f84957d19f96014c3edd12644
+Subproject commit 83d5aae0fce1956858c1b5595012e68867c53437
diff --git a/plugins/package.json b/plugins/package.json
index dc63a8c..0091b48 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,39 +3,49 @@
   "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
   "browser": true,
   "dependencies": {
-    "@codemirror/autocomplete": "^6.11.1",
-    "@codemirror/commands": "^6.3.3",
+    "@codemirror/autocomplete": "^6.18.2",
+    "@codemirror/commands": "^6.7.1",
     "@codemirror/lang-cpp": "^6.0.2",
-    "@codemirror/lang-css": "^6.2.1",
-    "@codemirror/lang-html": "^6.4.7",
+    "@codemirror/lang-css": "^6.3.0",
+    "@codemirror/lang-go": "^6.0.1",
+    "@codemirror/lang-html": "^6.4.9",
     "@codemirror/lang-java": "^6.0.1",
-    "@codemirror/lang-javascript": "^6.2.1",
+    "@codemirror/lang-javascript": "^6.2.2",
     "@codemirror/lang-json": "^6.0.1",
     "@codemirror/lang-less": "^6.0.2",
-    "@codemirror/lang-markdown": "^6.2.3",
+    "@codemirror/lang-markdown": "^6.3.1",
     "@codemirror/lang-php": "^6.0.1",
-    "@codemirror/lang-python": "^6.1.3",
+    "@codemirror/lang-python": "^6.1.6",
     "@codemirror/lang-rust": "^6.0.1",
     "@codemirror/lang-sass": "^6.0.2",
-    "@codemirror/lang-sql": "^6.5.5",
-    "@codemirror/lang-xml": "^6.0.2",
-    "@codemirror/language": "^6.9.1",
-    "@codemirror/language-data": "^6.3.1",
-    "@codemirror/legacy-modes": "^6.3.3",
-    "@codemirror/lint": "^6.4.2",
-    "@codemirror/search": "^6.5.5",
-    "@codemirror/state": "^6.4.0",
-    "@codemirror/view": "^6.23.0",
-    "@gerritcodereview/typescript-api": "3.8.0",
+    "@codemirror/lang-sql": "^6.8.0",
+    "@codemirror/lang-vue": "^0.1.3",
+    "@codemirror/lang-xml": "^6.1.0",
+    "@codemirror/lang-yaml": "^6.1.1",
+    "@codemirror/language": "^6.10.3",
+    "@codemirror/language-data": "^6.5.1",
+    "@codemirror/legacy-modes": "^6.4.2",
+    "@codemirror/lint": "^6.8.2",
+    "@codemirror/search": "^6.5.7",
+    "@codemirror/state": "^6.4.1",
+    "@codemirror/view": "^6.34.2",
+    "@gerritcodereview/typescript-api": "3.10.0",
     "@open-wc/testing": "^3.2.2",
     "@polymer/decorators": "^3.0.0",
-    "@polymer/polymer": "^3.5.1",
+    "@polymer/polymer": "3.5.1",
     "@web/dev-server-esbuild": "^0.3.6",
     "@web/test-runner": "^0.15.3",
-    "lit": "^3.1.0",
+    "lit": "^3.2.1",
     "rxjs": "^6.6.7",
     "sinon": "^13.0.2"
   },
+  "dependencies // comments": {
+    "@polymer/polymer": [
+      "There is a an issue with release 3.5.2. Tests are failing with:",
+      "NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':",
+      "the name 'dom-module' has already been used with this registry at ..."
+    ]
+  },
   "license": "Apache-2.0",
   "private": true
-}
+}
\ No newline at end of file
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index cdd2d2d..ed7870e 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit cdd2d2d69666a70a16ac02bacf8e7fbbf4ca9979
+Subproject commit ed7870eb3c8b6e48511d0eb3bd54606927b46019
diff --git a/plugins/replication b/plugins/replication
index aac2528..90c6420 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit aac252809094b8e4d4e26d69dab75a23d2da1770
+Subproject commit 90c64204e1e27ec245f41b3d08b10f431dd72faf
diff --git a/plugins/webhooks b/plugins/webhooks
index d864a2c..2e5ec3b 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit d864a2c71e261b39b89ea9425280fad425a336f3
+Subproject commit 2e5ec3b3bcf5e7ba50edba9eca3c15c8057ad6c2
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 844c4f1..2d7bd33 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -2,54 +2,57 @@
 # yarn lockfile v1
 
 
-"@75lb/deep-merge@^1.1.1":
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/@75lb/deep-merge/-/deep-merge-1.1.1.tgz#3b06155b90d34f5f8cc2107d796f1853ba02fd6d"
-  integrity sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==
-  dependencies:
-    lodash.assignwith "^4.2.0"
-    typical "^7.1.1"
-
 "@babel/code-frame@^7.12.11":
-  version "7.23.5"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244"
-  integrity sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==
+  version "7.24.7"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465"
+  integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==
   dependencies:
-    "@babel/highlight" "^7.23.4"
-    chalk "^2.4.2"
+    "@babel/highlight" "^7.24.7"
+    picocolors "^1.0.0"
 
-"@babel/helper-validator-identifier@^7.22.20":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
-  integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
+"@babel/helper-validator-identifier@^7.24.7":
+  version "7.24.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db"
+  integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==
 
-"@babel/highlight@^7.23.4":
-  version "7.23.4"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
-  integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==
+"@babel/highlight@^7.24.7":
+  version "7.24.7"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d"
+  integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.22.20"
+    "@babel/helper-validator-identifier" "^7.24.7"
     chalk "^2.4.2"
     js-tokens "^4.0.0"
+    picocolors "^1.0.0"
 
-"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.11.1", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1":
-  version "6.11.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.11.1.tgz#c733900eee58ac2de817317b9fd1e91b857c4329"
-  integrity sha512-L5UInv8Ffd6BPw0P3EF7JLYAMeEbclY7+6Q11REt8vhih8RuLreKtPy/xk8wPxs4EQgYqzI7cdgpiYwWlbS/ow==
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.18.2", "@codemirror/autocomplete@^6.7.1":
+  version "6.18.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.18.2.tgz#bf3f15f1bf0fdfa3b4fac560e419adae1ece8a94"
+  integrity sha512-wJGylKtMFR/Ds6Gh01+OovXE/pncPiKZNNBKuC39pKnH+XK5d9+WsNqcrdxPjFPFTigRBqse0rfxw9UxrfyhPg==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.17.0"
     "@lezer/common" "^1.0.0"
 
-"@codemirror/commands@^6.3.3":
-  version "6.3.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.3.3.tgz#03face5bf5f3de0fc4e09b177b3c91eda2ceb7e9"
-  integrity sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==
+"@codemirror/autocomplete@^6.3.2":
+  version "6.17.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.17.0.tgz#24ff5fc37fd91f6439df6f4ff9c8e910cde1b053"
+  integrity sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.17.0"
+    "@lezer/common" "^1.0.0"
+
+"@codemirror/commands@^6.7.1":
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.7.1.tgz#04561e95bc0779eaa49efd63e916c4efb3bbf6d6"
+  integrity sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.4.0"
-    "@codemirror/view" "^6.0.0"
+    "@codemirror/view" "^6.27.0"
     "@lezer/common" "^1.1.0"
 
 "@codemirror/lang-angular@^0.1.0":
@@ -72,7 +75,18 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/cpp" "^1.0.0"
 
-"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.2.0", "@codemirror/lang-css@^6.2.1":
+"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.3.0":
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.3.0.tgz#607628559f2471b385c6070ec795072a55cffc0b"
+  integrity sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.2"
+    "@lezer/css" "^1.1.7"
+
+"@codemirror/lang-css@^6.2.0":
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.2.1.tgz#5dc0a43b8e3c31f6af7aabd55ff07fe9aef2a227"
   integrity sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==
@@ -83,10 +97,21 @@
     "@lezer/common" "^1.0.2"
     "@lezer/css" "^1.0.0"
 
-"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.7":
-  version "6.4.7"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.7.tgz#e375e3c9ae898b5aca6e17b5055a3a76c7a8f5ff"
-  integrity sha512-y9hWSSO41XlcL4uYwWyk0lEgTHcelWWfRuqmvcAmxfCs0HNWZdriWo/EU43S63SxEZpc1Hd50Itw7ktfQvfkUg==
+"@codemirror/lang-go@^6.0.0", "@codemirror/lang-go@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-go/-/lang-go-6.0.1.tgz#598222c90f56eae28d11069c612ca64d0306b057"
+  integrity sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.6.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/go" "^1.0.0"
+
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.9":
+  version "6.4.9"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.9.tgz#d586f2cc9c341391ae07d1d7c545990dfa069727"
+  integrity sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/lang-css" "^6.0.0"
@@ -106,10 +131,10 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/java" "^1.0.0"
 
-"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.2.1":
-  version "6.2.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.1.tgz#8068d44365d13cdb044936fb4e3483301c12ef95"
-  integrity sha512-jlFOXTejVyiQCW3EQwvKH0m99bUYIw40oPmFjSX2VS78yzfe0HELZ+NEo9Yfo1MkGRpGlj3Gnu4rdxV1EnAs5A==
+"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.2.2":
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz#7141090b22994bef85bcc5608a3bc1257f2db2ad"
+  integrity sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.6.0"
@@ -138,17 +163,44 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.2.3":
-  version "6.2.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.2.3.tgz#ce572230a872e8eef88bce40213f26e66a7e4497"
-  integrity sha512-wCewRLWpdefWi7uVkHIDiE8+45Fe4buvMDZkihqEom5uRUQrl76Zb13emjeK3W+8pcRgRfAmwelURBbxNEKCIg==
+"@codemirror/lang-liquid@^6.0.0":
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-liquid/-/lang-liquid-6.2.1.tgz#78ded5e5b2aabbdf4687787ba9a29fce0da7e2ad"
+  integrity sha512-J1Mratcm6JLNEiX+U2OlCDTysGuwbHD76XwuL5o5bo9soJtSbz2g6RU3vGHFyS5DC8rgVmFSzi7i6oBftm7tnA==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.3.1"
+
+"@codemirror/lang-markdown@^6.0.0":
+  version "6.2.5"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz#451941bf743d3788e73598f1aedb71cbeb6f71ba"
+  integrity sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==
   dependencies:
     "@codemirror/autocomplete" "^6.7.1"
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/language" "^6.3.0"
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
-    "@lezer/common" "^1.0.0"
+    "@lezer/common" "^1.2.1"
+    "@lezer/markdown" "^1.0.0"
+
+"@codemirror/lang-markdown@^6.3.1":
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.3.1.tgz#067e4e18993fa3520e2a980d2dce5fe23dd245a0"
+  integrity sha512-y3sSPuQjBKZQbQwe3ZJKrSW6Silyl9PnrU/Mf0m2OQgIlPoSYTtOvEL7xs94SVMkb8f4x+SQFnzXPdX4Wk2lsg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.7.1"
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/language" "^6.3.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    "@lezer/common" "^1.2.1"
     "@lezer/markdown" "^1.0.0"
 
 "@codemirror/lang-php@^6.0.0", "@codemirror/lang-php@^6.0.1":
@@ -162,13 +214,15 @@
     "@lezer/common" "^1.0.0"
     "@lezer/php" "^1.0.0"
 
-"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.3":
-  version "6.1.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-python/-/lang-python-6.1.3.tgz#47b8d9fb42eb4482317843e519c6c211accacb62"
-  integrity sha512-S9w2Jl74hFlD5nqtUMIaXAq9t5WlM0acCkyuQWUUSvZclk1sV+UfnpFiZzuZSG+hfEaOmxKR5UxY/Uxswn7EhQ==
+"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.6":
+  version "6.1.6"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-python/-/lang-python-6.1.6.tgz#0c55e7e2dfa85b68be93b9692e5d3f76f284bbb2"
+  integrity sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==
   dependencies:
     "@codemirror/autocomplete" "^6.3.2"
     "@codemirror/language" "^6.8.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.2.1"
     "@lezer/python" "^1.1.4"
 
 "@codemirror/lang-rust@^6.0.0", "@codemirror/lang-rust@^6.0.1":
@@ -190,10 +244,10 @@
     "@lezer/common" "^1.0.2"
     "@lezer/sass" "^1.0.0"
 
-"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.5.5":
-  version "6.5.5"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.5.5.tgz#85619f4ea6738c07c0241b19c62d8ef86678e672"
-  integrity sha512-DvOaP2RXLb2xlxJxxydTFfwyYw5YDqEFea6aAfgh9UH0kUD6J1KFZ0xPgPpw1eo/5s2w3L6uh5PVR7GM23GxkQ==
+"@codemirror/lang-sql@^6.0.0":
+  version "6.7.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.7.0.tgz#a87fb9b458ae0ad1d8647c0234accca0ef11bb78"
+  integrity sha512-KMXp6rtyPYz6RaElvkh/77ClEAoQoHRPZo0zutRRialeFs/B/X8YaUJBCnAV2zqyeJPLZ4hgo48mG8TKoNXfZA==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.0.0"
@@ -202,7 +256,19 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@codemirror/lang-vue@^0.1.1":
+"@codemirror/lang-sql@^6.8.0":
+  version "6.8.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.8.0.tgz#1ae68ad49f378605ff88a4cc428ba667ce056068"
+  integrity sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.2.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-vue@^0.1.1", "@codemirror/lang-vue@^0.1.3":
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz#bf79b9152cc18b4903d64c1f67e186ae045c8a97"
   integrity sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==
@@ -224,30 +290,45 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@codemirror/lang-xml@^6.0.0", "@codemirror/lang-xml@^6.0.2":
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-xml/-/lang-xml-6.0.2.tgz#66f75390bf8013fd8645db9cdd0b1d177e0777a4"
-  integrity sha512-JQYZjHL2LAfpiZI2/qZ/qzDuSqmGKMwyApYmEUUCTxLM4MWS7sATUEfIguZQr9Zjx/7gcdnewb039smF6nC2zw==
+"@codemirror/lang-xml@^6.0.0", "@codemirror/lang-xml@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz#e3e786e1a89fdc9520efe75c1d6d3de1c40eb91c"
+  integrity sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.4.0"
     "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
     "@lezer/common" "^1.0.0"
     "@lezer/xml" "^1.0.0"
 
-"@codemirror/language-data@^6.3.1":
-  version "6.3.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.3.1.tgz#795ec09e04260868070296241363d70f4060bb36"
-  integrity sha512-p6jhJmvhGe1TG1EGNhwH7nFWWFSTJ8NDKnB2fVx5g3t+PpO0+63R7GJNxjS0TmmH3cdMxZbzejsik+rlEh1EyQ==
+"@codemirror/lang-yaml@^6.0.0", "@codemirror/lang-yaml@^6.1.1":
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-yaml/-/lang-yaml-6.1.1.tgz#6f6e4e16c5a4e6d549f462c9dc2053439e070d0d"
+  integrity sha512-HV2NzbK9bbVnjWxwObuZh5FuPCowx51mEfoFT9y3y+M37fA3+pbxx4I7uePuygFzDsAmCTwQSc/kXh/flab4uw==
+  dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.2.0"
+    "@lezer/highlight" "^1.2.0"
+    "@lezer/yaml" "^1.0.0"
+
+"@codemirror/language-data@^6.5.1":
+  version "6.5.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.5.1.tgz#5cb9413d5225ef27a577c23781bbc0b36c58bb67"
+  integrity sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==
   dependencies:
     "@codemirror/lang-angular" "^0.1.0"
     "@codemirror/lang-cpp" "^6.0.0"
     "@codemirror/lang-css" "^6.0.0"
+    "@codemirror/lang-go" "^6.0.0"
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/lang-java" "^6.0.0"
     "@codemirror/lang-javascript" "^6.0.0"
     "@codemirror/lang-json" "^6.0.0"
     "@codemirror/lang-less" "^6.0.0"
+    "@codemirror/lang-liquid" "^6.0.0"
     "@codemirror/lang-markdown" "^6.0.0"
     "@codemirror/lang-php" "^6.0.0"
     "@codemirror/lang-python" "^6.0.0"
@@ -257,13 +338,14 @@
     "@codemirror/lang-vue" "^0.1.1"
     "@codemirror/lang-wast" "^6.0.0"
     "@codemirror/lang-xml" "^6.0.0"
+    "@codemirror/lang-yaml" "^6.0.0"
     "@codemirror/language" "^6.0.0"
-    "@codemirror/legacy-modes" "^6.1.0"
+    "@codemirror/legacy-modes" "^6.4.0"
 
-"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0", "@codemirror/language@^6.9.1":
-  version "6.10.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.0.tgz#2d0e818716825ee2ed0dacd04595eaa61bae8f23"
-  integrity sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.10.3", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
+  version "6.10.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.3.tgz#eb25fc5ade19032e7bf1dcaa957804e5f1660585"
+  integrity sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.23.0"
@@ -272,40 +354,52 @@
     "@lezer/lr" "^1.0.0"
     style-mod "^4.0.0"
 
-"@codemirror/legacy-modes@^6.1.0", "@codemirror/legacy-modes@^6.3.3":
-  version "6.3.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.3.3.tgz#d7827c76c9533efdc76f7d0a0fc866f5acd4b764"
-  integrity sha512-X0Z48odJ0KIoh/HY8Ltz75/4tDYc9msQf1E/2trlxFaFFhgjpVHjZ/BCXe1Lk7s4Gd67LL/CeEEHNI+xHOiESg==
+"@codemirror/language@^6.8.0":
+  version "6.10.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.2.tgz#4056dc219619627ffe995832eeb09cea6060be61"
+  integrity sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.23.0"
+    "@lezer/common" "^1.1.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+    style-mod "^4.0.0"
+
+"@codemirror/legacy-modes@^6.4.0", "@codemirror/legacy-modes@^6.4.2":
+  version "6.4.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.4.2.tgz#723a55aae21304d4c112575943d3467c9040d217"
+  integrity sha512-HsvWu08gOIIk303eZQCal4H4t65O/qp1V4ul4zVa3MHK5FJ0gz3qz3O55FIkm+aQUcshUOjBx38t2hPiJwW5/g==
   dependencies:
     "@codemirror/language" "^6.0.0"
 
-"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.4.2":
-  version "6.4.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.4.2.tgz#c13be5320bde9707efdc94e8bcd3c698abae0b92"
-  integrity sha512-wzRkluWb1ptPKdzlsrbwwjYCPLgzU6N88YBAmlZi8WFyuiEduSd05MnJYNogzyc8rPK7pj6m95ptUApc8sHKVA==
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.8.2":
+  version "6.8.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.2.tgz#7864b03583e9efd18554cff1dd4504da10338ab1"
+  integrity sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
     crelt "^1.0.5"
 
-"@codemirror/search@^6.5.5":
-  version "6.5.5"
-  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.5.tgz#cf97e201da364da2285c2a250167af25bbd2a4a2"
-  integrity sha512-PIEN3Ke1buPod2EHbJsoQwlbpkz30qGZKcnmH1eihq9+bPQx8gelauUwLYaY4vBOuBAuEhmpDLii4rj/uO0yMA==
+"@codemirror/search@^6.5.7":
+  version "6.5.7"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.7.tgz#fb60f6637437a8264f86079621a56290dc3814c4"
+  integrity sha512-6+iLsXvITWKHYlkgHPCs/qiX4dNzn8N78YfhOFvPtPYCkuXqZq10rAfsUMhOq7O/1VjJqdXRflyExlfVcu/9VQ==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
     crelt "^1.0.5"
 
-"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0":
-  version "6.4.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.0.tgz#8bc3e096c84360b34525a84696a84f86b305363a"
-  integrity sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.4.1":
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.1.tgz#da57143695c056d9a3c38705ed34136e2b68171b"
+  integrity sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==
 
-"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0":
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.23.0.tgz#8054a2043273abad7f1587d15accb0623e1960ed"
-  integrity sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.34.2":
+  version "6.34.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.34.2.tgz#c6cc1387be217f448af585f05f23e681f76aeda7"
+  integrity sha512-d6n0WFvL970A9Z+l9N2dO+Hk9ev4hDYQzIx+B9tCyBP0W5wPEszi1rhuyFesNSkLZzXbQE5FPH7F/z/TMJfoPA==
   dependencies:
     "@codemirror/state" "^6.4.0"
     style-mod "^4.1.0"
@@ -428,33 +522,38 @@
   dependencies:
     "@types/chai" "^4.2.12"
 
-"@gerritcodereview/typescript-api@3.8.0":
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.8.0.tgz#2e418b814d7451c40365b2dc4f88e9965ece0769"
-  integrity sha512-wUkIWUx99Rj1vxRYQISxyzN0nplqu7t5sRDyJ8R3yNNkvALQAMC6Whj63qzCsZsymVFzC5up3y+ZVxaeh7b+xA==
+"@gerritcodereview/typescript-api@3.10.0":
+  version "3.10.0"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.10.0.tgz#dce07e688fc9b9dd19163df90716d0d73587dcb8"
+  integrity sha512-+ogu+62H4HkGEfEotX3yuNaP6PRkDpG7+gUQPvzAhTwo9u2NvQIeVfETU8ktjLGZd5mMx7htRvTpCaEZEe6MTA==
+
+"@hapi/bourne@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7"
+  integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==
 
 "@jridgewell/resolve-uri@^3.1.0":
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
-  integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
+  integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
 
 "@jridgewell/sourcemap-codec@^1.4.14":
-  version "1.4.15"
-  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
-  integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a"
+  integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==
 
 "@jridgewell/trace-mapping@^0.3.12":
-  version "0.3.20"
-  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f"
-  integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==
+  version "0.3.25"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0"
+  integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==
   dependencies:
     "@jridgewell/resolve-uri" "^3.1.0"
     "@jridgewell/sourcemap-codec" "^1.4.14"
 
-"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.0.tgz#f10493d12c4a196a02ff5fcf5695a516a4039aae"
-  integrity sha512-Wmvlm4q6tRpwiy20TnB3yyLTZim38Tkc50dPY8biQRwqE+ati/wD84rm3N15hikvdT4uSg9phs9ubjvcLmkpKg==
+"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.2.1":
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.3.tgz#138fcddab157d83da557554851017c6c1e5667fd"
+  integrity sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==
 
 "@lezer/cpp@^1.0.0":
   version "1.1.2"
@@ -465,16 +564,32 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@lezer/css@^1.0.0", "@lezer/css@^1.1.0":
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.6.tgz#b887d8f66d3d7b9b61a4c614a0ce923e05eee6dc"
-  integrity sha512-/HhbnfXchRc995VdDH9TBzd1B2CO/A4uhOhELqGjd7Bymgc+tGlb0W9Vp5GA1Otq8Ef4JCXpuKmr4hH3aFny6A==
+"@lezer/css@^1.0.0", "@lezer/css@^1.1.0", "@lezer/css@^1.1.7":
+  version "1.1.9"
+  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.9.tgz#404563d361422c5a1fe917295f1527ee94845ed1"
+  integrity sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==
+  dependencies:
+    "@lezer/common" "^1.2.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@lezer/go@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@lezer/go/-/go-1.0.0.tgz#26cd2463f8583e630f52e714dca6d7420c5f7d7e"
+  integrity sha512-co9JfT3QqX1YkrMmourYw2Z8meGC50Ko4d54QEcQbEYpvdUvN4yb0NBZdn/9ertgvjsySxHsKzH3lbm3vqJ4Jw==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.1.tgz#596fa8f9aeb58a608be0a563e960c373cbf23f8b"
+  integrity sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==
+  dependencies:
+    "@lezer/common" "^1.0.0"
+
+"@lezer/highlight@^1.2.0":
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.0.tgz#e5898c3644208b4b589084089dceeea2966f7780"
   integrity sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==
@@ -482,28 +597,29 @@
     "@lezer/common" "^1.0.0"
 
 "@lezer/html@^1.3.0":
-  version "1.3.8"
-  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.8.tgz#e0c8b28f91607787ab6696a1dd802c0c38f679e4"
-  integrity sha512-EXseJ3pUzWxE6XQBQdqWHZqqlGQRSuNMBcLb6mZWS2J2v+QZhOObD+3ZIKIcm59ntTzyor4LqFTb72iJc3k23Q==
+  version "1.3.10"
+  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.10.tgz#1be9a029a6fe835c823b20a98a449a630416b2af"
+  integrity sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/java@^1.0.0":
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.1.1.tgz#eed8813a5f3eb1a913aa8eaf40d5b20f40dee3d6"
-  integrity sha512-mt3dX13fRlpY7RlWELYRakanXgmwXsLRCrhstrn+c1sZd7jR2xle46/3heoxGd+oHxnuTnpoyXTyxcLJQs9+mQ==
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.1.2.tgz#01a6ffefa9a692ac6cd492f8b924009edcb903d7"
+  integrity sha512-3j8X70JvYf0BZt8iSRLXLkt0Ry1hVUgH6wT32yBxH/Xi55nW2VMhc1Az4SKwu4YGSmxCm1fsqDDcHTuFjC8pmg==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/javascript@^1.0.0":
-  version "1.4.11"
-  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.11.tgz#4ca7681e29fda6f960e15d958ee4f4ceaf577223"
-  integrity sha512-B5Y9EJF4BWiMgj4ufxUo2hrORnmMBDrMtR+L7dwIO5pocuSAahG6QBwXR6PbKJOjRywJczU2r2LJPg79ER91TQ==
+  version "1.4.19"
+  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.19.tgz#7c8c8e5052537d8c8ddcae428e270227aadbddc9"
+  integrity sha512-j44kbR1QL26l6dMunZ1uhKBFteVGLVCBGNUD2sUaMnic+rbTviVuoK0CD1l9FTW31EueWvFFswCKMH7Z+M3JRA==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.1.3"
     "@lezer/lr" "^1.3.0"
 
@@ -516,17 +632,17 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1", "@lezer/lr@^1.3.3":
-  version "1.3.14"
-  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.14.tgz#59d4a3b25698bdac0ef182fa6eadab445fc4f29a"
-  integrity sha512-z5mY4LStlA3yL7aHT/rqgG614cfcvklS+8oFRFBYrs4YaWLJyKKM4+nN6KopToX0o9Hj6zmH6M5kinOYuy06ug==
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1", "@lezer/lr@^1.3.3", "@lezer/lr@^1.4.0":
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.2.tgz#931ea3dea8e9de84e90781001dae30dea9ff1727"
+  integrity sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==
   dependencies:
     "@lezer/common" "^1.0.0"
 
 "@lezer/markdown@^1.0.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.2.0.tgz#387cd5fba85479e3fa1d74586060dc5392c9ccb6"
-  integrity sha512-d7MwsfAukZJo1GpPrcPGa3MxaFFOqNp0gbqF+3F7pTeNDOgeJN1muXzx1XXDPt+Ac+/voCzsH7qXqnn+xReG/g==
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.3.2.tgz#9d648b2a6cb47523f3d7ab494eee8c7be4f1ea9e"
+  integrity sha512-Wu7B6VnrKTbBEohqa63h5vxXjiC4pO5ZQJ/TDbhJxPQaaIoRD/6UVDhSDtVsCwVZV12vvN9KxuLL3ATMnlG0oQ==
   dependencies:
     "@lezer/common" "^1.0.0"
     "@lezer/highlight" "^1.0.0"
@@ -541,9 +657,9 @@
     "@lezer/lr" "^1.1.0"
 
 "@lezer/python@^1.1.4":
-  version "1.1.10"
-  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.10.tgz#580160705ef5b557d8829fd2bf8f09dc9a91a0fb"
-  integrity sha512-pvSjn+OWivmA/si/SFeGouHO50xoOZcPIFzf8dql0gRvcfCvLDpVIpnnGFFlB7wa0WDscDLo0NmH+4Tx80nBdQ==
+  version "1.1.14"
+  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.14.tgz#a0887086fb7645cd09ada38ed748ca1d968e6363"
+  integrity sha512-ykDOb2Ti24n76PJsSa4ZoDF0zH12BSw1LGfQXCYJhJyOGiFTfGaX0Du66Ze72R+u/P35U+O6I9m8TFXov1JzsA==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
@@ -559,34 +675,43 @@
     "@lezer/lr" "^1.0.0"
 
 "@lezer/sass@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.4.tgz#5d18c460a4a896145ac49bab8ea7998ac9d9b401"
-  integrity sha512-AqW4myvp73sbMk6y0+gJrMjN5xtqFZzqTftzO3YcO8gSL5d3pymIP3deQllAI8+s1ZoSzH6kD4hsoFLpkD9Kfg==
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.6.tgz#2ba5294c6995023988e7971fc04757bc0d83b120"
+  integrity sha512-w/RCO2dIzZH1To8p+xjs8cE+yfgGus8NZ/dXeWl/QzHyr+TeBs71qiE70KPImEwvTsmEjoWh0A5SxMzKd5BWBQ==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/xml@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.4.tgz#d565dd84af9ec0f620b0bb5f043b1233e63ffb0a"
-  integrity sha512-WmXKb5eX8+rRfZYSNRR5TPee/ZoDgBdVS/rj1VCJGDKa5gNldIctQYibCoFVyNhvZsyL/8nHbZJZPM4gnXN2Vw==
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.5.tgz#4bb7fd3e527f41b78372477aa753f035b41c3846"
+  integrity sha512-VFouqOzmUWfIg+tfmpcdV33ewtK+NSwd4ngSe1aG7HFb4BN0ExyY1b8msp+ndFrnlG4V4iC8yXacjFtrwERnaw==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@lit-labs/ssr-dom-shim@^1.1.2":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312"
-  integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==
-
-"@lit/reactive-element@^1.0.0 || ^2.0.0", "@lit/reactive-element@^2.0.0":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.2.tgz#779ae9d265407daaf7737cb892df5ec2a86e22a0"
-  integrity sha512-SVOwLAWUQg3Ji1egtOt1UiFe4zdDpnWHyc5qctSceJ5XIu0Uc76YmGpIjZgx9YJ0XtdW0Jm507sDvjOu+HnB8w==
+"@lezer/yaml@^1.0.0":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@lezer/yaml/-/yaml-1.0.3.tgz#b23770ab42b390056da6b187d861b998fd60b1ff"
+  integrity sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2"
+    "@lezer/common" "^1.2.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.4.0"
+
+"@lit-labs/ssr-dom-shim@^1.2.0":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz#2f3a8f1d688935c704dbc89132394a41029acbb8"
+  integrity sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==
+
+"@lit/reactive-element@^1.0.0 || ^2.0.0", "@lit/reactive-element@^2.0.4":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.4.tgz#8f2ed950a848016383894a26180ff06c56ae001b"
+  integrity sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==
+  dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.2.0"
 
 "@mdn/browser-compat-data@^4.0.0":
   version "4.2.1"
@@ -663,7 +788,7 @@
   dependencies:
     "@polymer/polymer" "^3.0.5"
 
-"@polymer/polymer@^3.0.5", "@polymer/polymer@^3.5.1":
+"@polymer/polymer@3.5.1", "@polymer/polymer@^3.0.5":
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.1.tgz#4b5234e43b8876441022bcb91313ab3c4a29f0c8"
   integrity sha512-JlAHuy+1qIC6hL1ojEUfIVD58fzTpJAoCxFwV5yr0mYTXV1H8bz5zy0+rC963Cgr9iNXQ4T9ncSjC2fkF9BQfw==
@@ -712,24 +837,17 @@
   dependencies:
     type-detect "4.0.8"
 
-"@sinonjs/commons@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3"
-  integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==
-  dependencies:
-    type-detect "4.0.8"
-
 "@sinonjs/commons@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.0.tgz#beb434fe875d965265e04722ccfc21df7f755d72"
-  integrity sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd"
+  integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==
   dependencies:
     type-detect "4.0.8"
 
-"@sinonjs/fake-timers@^10.0.2":
-  version "10.3.0"
-  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66"
-  integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==
+"@sinonjs/fake-timers@^11.2.2":
+  version "11.2.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699"
+  integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==
   dependencies:
     "@sinonjs/commons" "^3.0.0"
 
@@ -749,7 +867,7 @@
     lodash.get "^4.4.2"
     type-detect "^4.0.8"
 
-"@sinonjs/text-encoding@^0.7.1":
+"@sinonjs/text-encoding@^0.7.2":
   version "0.7.2"
   resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918"
   integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==
@@ -782,9 +900,9 @@
     "@types/chai" "*"
 
 "@types/chai@*", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
-  version "4.3.11"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.11.tgz#e95050bf79a932cb7305dd130254ccdf9bde671c"
-  integrity sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==
+  version "4.3.17"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.17.tgz#9195f9d242f2ac3b429908864b6b871a8f73f489"
+  integrity sha512-zmZ21EWzR71B4Sscphjief5djsLre50M6lI622OSySTmn9DB3j+C3kWroHfBQWXbOBwbgg/M8CG/hUxDLIloow==
 
 "@types/co-body@^6.1.0":
   version "6.1.3"
@@ -817,9 +935,9 @@
   integrity sha512-ag0BfJLZf6CQz8VIuRIEYQ5Ggwk/82uvTQf27RcpyDNbY0Vw49LIPqAxk5tqYfrCs9xDaIMvl4aj7ZopnYL8bA==
 
 "@types/cookies@*":
-  version "0.7.10"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.10.tgz#c4881dca4dd913420c488508d192496c46eb4fd0"
-  integrity sha512-hmUCjAk2fwZVPPkkPBcI7jGLIR5mg4OVoNMBwU6aVsMm/iNPY7z9/R+x2fSwLt/ZXoGua6C5Zy2k5xOo9jUyhQ==
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.9.0.tgz#a2290cfb325f75f0f28720939bee854d4142aee2"
+  integrity sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -837,9 +955,9 @@
   integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
 "@types/express-serve-static-core@^4.17.33":
-  version "4.17.41"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6"
-  integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==
+  version "4.19.5"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz#218064e321126fcf9048d1ca25dd2465da55d9c6"
+  integrity sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
@@ -898,9 +1016,9 @@
     "@types/koa" "*"
 
 "@types/koa@*", "@types/koa@^2.11.6":
-  version "2.13.12"
-  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.12.tgz#70d87a9061a81909e0ee11ca50168416e8d3e795"
-  integrity sha512-vAo1KuDSYWFDB4Cs80CHvfmzSQWeUb909aQib0C0aFx4sw0K9UZFz2m5jaEP+b3X1+yr904iQiruS0hXi31jbw==
+  version "2.15.0"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.15.0.tgz#eca43d76f527c803b491731f95df575636e7b6f2"
+  integrity sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -911,11 +1029,6 @@
     "@types/koa-compose" "*"
     "@types/node" "*"
 
-"@types/mime@*":
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45"
-  integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==
-
 "@types/mime@^1":
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
@@ -927,11 +1040,11 @@
   integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
 
 "@types/node@*":
-  version "20.10.6"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.6.tgz#a3ec84c22965802bf763da55b2394424f22bfbb5"
-  integrity sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==
+  version "22.0.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-22.0.2.tgz#9fb1a2b31970871e8bf696f0e8a40d2e6d2bd04e"
+  integrity sha512-yPL6DyFwY5PiMVEwymNeqUTKsDczQBJ/5T7W/46RwLU/VH+AA8aT5TZkvBviLKLbbm0hlfftEkGrNzfRk/fofQ==
   dependencies:
-    undici-types "~5.26.4"
+    undici-types "~6.11.1"
 
 "@types/parse5@^6.0.1":
   version "6.0.3"
@@ -939,9 +1052,9 @@
   integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
 "@types/qs@*":
-  version "6.9.11"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.11.tgz#208d8a30bc507bd82e03ada29e4732ea46a6bbda"
-  integrity sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==
+  version "6.9.15"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce"
+  integrity sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==
 
 "@types/range-parser@*":
   version "1.2.7"
@@ -964,13 +1077,13 @@
     "@types/node" "*"
 
 "@types/serve-static@*":
-  version "1.15.5"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033"
-  integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==
+  version "1.15.7"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.7.tgz#22174bbd74fb97fe303109738e9b5c2f3064f714"
+  integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==
   dependencies:
     "@types/http-errors" "*"
-    "@types/mime" "*"
     "@types/node" "*"
+    "@types/send" "*"
 
 "@types/sinon-chai@^3.2.3":
   version "3.2.12"
@@ -981,9 +1094,9 @@
     "@types/sinon" "*"
 
 "@types/sinon@*":
-  version "17.0.2"
-  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.2.tgz#9a769f67e62b45b7233f1fe01cb1f231d2393e1c"
-  integrity sha512-Zt6heIGsdqERkxctIpvN5Pv3edgBrhoeb3yHyxffd4InN0AX2SVNKSrhdDZKGQICVOxWP/q4DyhpfPNMSrpIiA==
+  version "17.0.3"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa"
+  integrity sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==
   dependencies:
     "@types/sinonjs__fake-timers" "*"
 
@@ -1056,10 +1169,10 @@
     picomatch "^2.2.2"
     ws "^7.4.2"
 
-"@web/dev-server-core@^0.7.0":
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.7.0.tgz#ffe71dd272ecb73a2b0c1ee23f3fad812780b998"
-  integrity sha512-1FJe6cJ3r0x0ZmxY/FnXVduQD4lKX7QgYhyS6N+VmIpV+tBU4sGRbcrmeoYeY+nlnPa6p2oNuonk3X5ln/W95g==
+"@web/dev-server-core@^0.7.2":
+  version "0.7.2"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.7.2.tgz#a4f4808bd709257f44b1218cd2663de3a7c5b317"
+  integrity sha512-Q/0jpF13Ipk+qGGQ+Yx/FW1TQBYazpkfgYHHo96HBE7qv4V4KKHqHglZcSUxti/zd4bToxX1cFTz8dmbTlb8JA==
   dependencies:
     "@types/koa" "^2.11.6"
     "@types/ws" "^7.4.0"
@@ -1198,9 +1311,9 @@
     source-map "^0.7.3"
 
 "@web/test-runner-core@^0.13.0":
-  version "0.13.0"
-  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.13.0.tgz#a3799461002fcb969b0baa100d88be6c1ff504f4"
-  integrity sha512-mUrETPg9n4dHWEk+D46BU3xVhQf+ljT4cG7FSpmF7AIOsXWgWHoaXp6ReeVcEmM5fmznXec2O/apTb9hpGrP3w==
+  version "0.13.3"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.13.3.tgz#ca74b476d6aedefed6c8d04ebd3556717199ec6b"
+  integrity sha512-ilDqF/v2sj0sD69FNSIDT7uw4M1yTVedLBt32/lXy3MMi6suCM7m/ZlhsBy8PXhf879WMvzBOl/vhJBpEMB9vA==
   dependencies:
     "@babel/code-frame" "^7.12.11"
     "@types/babel__code-frame" "^7.0.2"
@@ -1210,7 +1323,7 @@
     "@types/istanbul-lib-coverage" "^2.0.3"
     "@types/istanbul-reports" "^3.0.0"
     "@web/browser-logs" "^0.4.0"
-    "@web/dev-server-core" "^0.7.0"
+    "@web/dev-server-core" "^0.7.2"
     chokidar "^3.4.3"
     cli-cursor "^3.1.0"
     co-body "^6.1.0"
@@ -1218,7 +1331,7 @@
     debounce "^1.2.0"
     dependency-graph "^0.11.0"
     globby "^11.0.1"
-    ip "^1.1.5"
+    internal-ip "^6.2.0"
     istanbul-lib-coverage "^3.0.0"
     istanbul-lib-report "^3.0.1"
     istanbul-reports "^3.0.2"
@@ -1351,9 +1464,9 @@
     lodash "^4.17.14"
 
 axe-core@^4.3.3:
-  version "4.8.3"
-  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.3.tgz#205df863dd9917d5979e9435dab4d47692759051"
-  integrity sha512-d5ZQHPSPkF9Tw+yfyDcRoUOc4g/8UloJJe5J8m4L5+c7AtDdjDLRxew/knnI4CxvtdxEUVgWz4x3OIQUIFiMfw==
+  version "4.10.0"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59"
+  integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==
 
 base64-js@^1.3.1:
   version "1.5.1"
@@ -1361,9 +1474,9 @@
   integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
 
 binary-extensions@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
-  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
+  integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
 
 bl@^4.0.3:
   version "4.1.0"
@@ -1374,12 +1487,12 @@
     inherits "^2.0.4"
     readable-stream "^3.4.0"
 
-braces@^3.0.2, braces@~3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
-  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+braces@^3.0.3, braces@~3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+  integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
   dependencies:
-    fill-range "^7.0.1"
+    fill-range "^7.1.1"
 
 buffer-crc32@~0.2.3:
   version "0.2.13"
@@ -1412,14 +1525,16 @@
     mime-types "^2.1.18"
     ylru "^1.2.0"
 
-call-bind@^1.0.0:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513"
-  integrity sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==
+call-bind@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
+  integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==
   dependencies:
+    es-define-property "^1.0.0"
+    es-errors "^1.3.0"
     function-bind "^1.1.2"
-    get-intrinsic "^1.2.1"
-    set-function-length "^1.1.1"
+    get-intrinsic "^1.2.4"
+    set-function-length "^1.2.1"
 
 camelcase@^6.2.0:
   version "6.3.0"
@@ -1458,9 +1573,9 @@
     supports-color "^7.1.0"
 
 chokidar@^3.4.3:
-  version "3.5.3"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
-  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
+  integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
   dependencies:
     anymatch "~3.1.2"
     braces "~3.0.2"
@@ -1516,10 +1631,11 @@
   integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
 
 co-body@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.1.0.tgz#d87a8efc3564f9bfe3aced8ef5cd04c7a8766547"
-  integrity sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.2.0.tgz#afd776d60e5659f4eee862df83499698eb1aea1b"
+  integrity sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==
   dependencies:
+    "@hapi/bourne" "^3.0.0"
     inflation "^2.0.0"
     qs "^6.5.2"
     raw-body "^2.3.3"
@@ -1554,7 +1670,7 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
-command-line-args@^5.1.1, command-line-args@^5.2.1:
+command-line-args@^5.1.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
   integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
@@ -1564,14 +1680,14 @@
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
 
-command-line-usage@^7.0.0, command-line-usage@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-7.0.1.tgz#e540afef4a4f3bc501b124ffde33956309100655"
-  integrity sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==
+command-line-usage@^7.0.1:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-7.0.3.tgz#6bce992354f6af10ecea2b631bfdf0c8b3bfaea3"
+  integrity sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==
   dependencies:
     array-back "^6.2.2"
     chalk-template "^0.4.0"
-    table-layout "^3.0.0"
+    table-layout "^4.1.0"
     typical "^7.1.1"
 
 content-disposition@~0.5.2:
@@ -1611,12 +1727,28 @@
   dependencies:
     node-fetch "2.6.7"
 
+cross-spawn@^7.0.3:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
 debounce@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
   integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
 
-debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.2:
+debug@4, debug@^4.1.1, debug@^4.3.2:
+  version "4.3.6"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b"
+  integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==
+  dependencies:
+    ms "2.1.2"
+
+debug@4.3.4:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
   integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@@ -1647,14 +1779,21 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
   integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
-define-data-property@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3"
-  integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==
+default-gateway@^6.0.0:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-6.0.3.tgz#819494c888053bdb743edbf343d6cdf7f2943a71"
+  integrity sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==
   dependencies:
-    get-intrinsic "^1.2.1"
+    execa "^5.0.0"
+
+define-data-property@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
+  integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
+  dependencies:
+    es-define-property "^1.0.0"
+    es-errors "^1.3.0"
     gopd "^1.0.1"
-    has-property-descriptors "^1.0.0"
 
 define-lazy-prop@^2.0.0:
   version "2.0.0"
@@ -1692,9 +1831,9 @@
   integrity sha512-yIR+pG9x65Xko7bErCUSQaDLrO/P1p3JUzEk7JCU4DowPcGHkTGUGQapcfcLc4qj0UaALwZ+cr0riFgiqpixcg==
 
 diff@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
-  integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
+  integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
 
 dir-glob@^3.0.1:
   version "3.0.1"
@@ -1730,10 +1869,22 @@
   resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.1.tgz#05adf6de1f5b04a66f2c12cc0593e1be2b18cd0f"
   integrity sha512-jE4i0SMYevwu/xxAuzhly/KTwtj0xDhbzB6m1xPImxTkw8wcCbgarOQPfCVMi5JKVyW7in29pNJCCJrry3Ynnw==
 
+es-define-property@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845"
+  integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==
+  dependencies:
+    get-intrinsic "^1.2.4"
+
+es-errors@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
+  integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
+
 es-module-lexer@^1.0.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5"
-  integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==
+  version "1.5.4"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78"
+  integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==
 
 "esbuild@^0.16 || ^0.17":
   version "0.17.19"
@@ -1764,9 +1915,9 @@
     "@esbuild/win32-x64" "0.17.19"
 
 escalade@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
-  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27"
+  integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==
 
 escape-html@^1.0.3:
   version "1.0.3"
@@ -1793,6 +1944,21 @@
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
   integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
 
+execa@^5.0.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
+  integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
+  dependencies:
+    cross-spawn "^7.0.3"
+    get-stream "^6.0.0"
+    human-signals "^2.1.0"
+    is-stream "^2.0.0"
+    merge-stream "^2.0.0"
+    npm-run-path "^4.0.1"
+    onetime "^5.1.2"
+    signal-exit "^3.0.3"
+    strip-final-newline "^2.0.0"
+
 extract-zip@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
@@ -1816,9 +1982,9 @@
     micromatch "^4.0.4"
 
 fastq@^1.6.0:
-  version "1.16.0"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320"
-  integrity sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
+  integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==
   dependencies:
     reusify "^1.0.4"
 
@@ -1829,10 +1995,10 @@
   dependencies:
     pend "~1.2.0"
 
-fill-range@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
-  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+fill-range@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+  integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
   dependencies:
     to-regex-range "^5.0.1"
 
@@ -1868,11 +2034,12 @@
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-intrinsic@^1.0.2, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.2.tgz#281b7622971123e1ef4b3c90fd7539306da93f3b"
-  integrity sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==
+get-intrinsic@^1.1.3, get-intrinsic@^1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd"
+  integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==
   dependencies:
+    es-errors "^1.3.0"
     function-bind "^1.1.2"
     has-proto "^1.0.1"
     has-symbols "^1.0.3"
@@ -1926,34 +2093,34 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
-has-property-descriptors@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz#52ba30b6c5ec87fd89fa574bc1c39125c6f65340"
-  integrity sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==
+has-property-descriptors@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
+  integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
   dependencies:
-    get-intrinsic "^1.2.2"
+    es-define-property "^1.0.0"
 
 has-proto@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0"
-  integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd"
+  integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==
 
-has-symbols@^1.0.2, has-symbols@^1.0.3:
+has-symbols@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
   integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
 
 has-tostringtag@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
-  integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
+  integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
   dependencies:
-    has-symbols "^1.0.2"
+    has-symbols "^1.0.3"
 
-hasown@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
-  integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==
+hasown@^2.0.0, hasown@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
+  integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
   dependencies:
     function-bind "^1.1.2"
 
@@ -2010,6 +2177,11 @@
     agent-base "6"
     debug "4"
 
+human-signals@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
+  integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
+
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -2023,9 +2195,9 @@
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
 ignore@^5.2.0:
-  version "5.3.0"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
-  integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
+  integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
 
 inflation@^2.0.0:
   version "2.1.0"
@@ -2042,10 +2214,30 @@
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
+internal-ip@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-6.2.0.tgz#d5541e79716e406b74ac6b07b856ef18dc1621c1"
+  integrity sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==
+  dependencies:
+    default-gateway "^6.0.0"
+    ipaddr.js "^1.9.1"
+    is-ip "^3.1.0"
+    p-event "^4.2.0"
+
+ip-regex@^4.0.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5"
+  integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==
+
 ip@^1.1.5:
-  version "1.1.8"
-  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
-  integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
+  version "1.1.9"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.9.tgz#8dfbcc99a754d07f425310b86a99546b1151e396"
+  integrity sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==
+
+ipaddr.js@^1.9.1:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
+  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
 
 is-binary-path@~2.1.0:
   version "2.1.0"
@@ -2062,11 +2254,11 @@
     builtin-modules "^3.3.0"
 
 is-core-module@^2.13.0:
-  version "2.13.1"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
-  integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
+  version "2.15.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.0.tgz#71c72ec5442ace7e76b306e9d48db361f22699ea"
+  integrity sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==
   dependencies:
-    hasown "^2.0.0"
+    hasown "^2.0.2"
 
 is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
@@ -2097,6 +2289,13 @@
   dependencies:
     is-extglob "^2.1.1"
 
+is-ip@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8"
+  integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==
+  dependencies:
+    ip-regex "^4.0.0"
+
 is-module@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
@@ -2119,15 +2318,15 @@
   dependencies:
     is-docker "^2.0.0"
 
-isarray@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
-  integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==
-
 isbinaryfile@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-5.0.0.tgz#034b7e54989dab8986598cbcea41f66663c65234"
-  integrity sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg==
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-5.0.2.tgz#fe6e4dfe2e34e947ffa240c113444876ba393ae0"
+  integrity sha512-GvcjojwonMjWbTkfMpnVHVqXW/wKMYDfEpY94/8zy8HFMOqb/VL6oeONq9v87q4ttVlaTLnGXnJD4B5B1OTGIg==
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
 istanbul-lib-coverage@^3.0.0:
   version "3.2.2"
@@ -2144,9 +2343,9 @@
     supports-color "^7.1.0"
 
 istanbul-reports@^3.0.2:
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a"
-  integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==
+  version "3.1.7"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b"
+  integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==
   dependencies:
     html-escaper "^2.0.0"
     istanbul-lib-report "^3.0.0"
@@ -2156,10 +2355,10 @@
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-just-extend@^4.0.2:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
-  integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
+just-extend@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947"
+  integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==
 
 keygrip@~1.1.0:
   version "1.1.0"
@@ -2206,9 +2405,9 @@
     koa-send "^5.0.0"
 
 koa@^2.13.0:
-  version "2.15.0"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.0.tgz#d24ae1b0ff378bf12eb3df584ab4204e4c12ac2b"
-  integrity sha512-KEL/vU1knsoUvfP4MC4/GthpQrY/p6dzwaaGI6Rt4NQuFqkw3qrvsdYF5pz3wOfi7IGTvMPHC9aZIcUKYFNxsw==
+  version "2.15.3"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.3.tgz#062809266ee75ce0c75f6510a005b0e38f8c519a"
+  integrity sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
@@ -2242,35 +2441,46 @@
     debug "^2.6.9"
     marky "^1.2.2"
 
-lit-element@^4.0.0:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.2.tgz#1a519896d5ab7c7be7a8729f400499e38779c093"
-  integrity sha512-/W6WQZUa5VEXwC7H9tbtDMdSs9aWil3Ou8hU6z2cOKWbsm/tXPAcsoaHVEtrDo0zcOIE5GF6QgU55tlGL2Nihg==
+lit-element@^4.0.4, lit-element@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.1.1.tgz#07905992815076e388cf6f1faffc7d6866c82007"
+  integrity sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2"
-    "@lit/reactive-element" "^2.0.0"
-    lit-html "^3.1.0"
+    "@lit-labs/ssr-dom-shim" "^1.2.0"
+    "@lit/reactive-element" "^2.0.4"
+    lit-html "^3.2.0"
 
-"lit-html@^2.0.0 || ^3.0.0", lit-html@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.1.0.tgz#a7b93dd682073f2e2029656f4e9cd91e8034c196"
-  integrity sha512-FwAjq3iNsaO6SOZXEIpeROlJLUlrbyMkn4iuv4f4u1H40Jw8wkeR/OUXZUHUoiYabGk8Y4Y0F/rgq+R4MrOLmA==
+"lit-html@^2.0.0 || ^3.0.0":
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.1.4.tgz#30ad4f11467a61e2f08856de170e343184e9034e"
+  integrity sha512-yKKO2uVv7zYFHlWMfZmqc+4hkmSbFp8jgjdZY9vvR9jr4J8fH6FUMXhr+ljfELgmjpvlF7Z1SJ5n5/Jeqtc9YA==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-"lit@^2.0.0 || ^3.0.0", lit@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-3.1.0.tgz#76429b85dc1f5169fed499a0f7e89e2e619010c9"
-  integrity sha512-rzo/hmUqX8zmOdamDAeydfjsGXbbdtAFqMhmocnh2j9aDYqbu0fjXygjCa0T99Od9VQ/2itwaGrjZz/ZELVl7w==
+lit-html@^3.1.2, lit-html@^3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.2.1.tgz#8fc49e3531ee5947e4d93e8a5aa642ab1649833b"
+  integrity sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==
   dependencies:
-    "@lit/reactive-element" "^2.0.0"
-    lit-element "^4.0.0"
-    lit-html "^3.1.0"
+    "@types/trusted-types" "^2.0.2"
 
-lodash.assignwith@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.assignwith/-/lodash.assignwith-4.2.0.tgz#127a97f02adc41751a954d24b0de17e100e038eb"
-  integrity sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==
+"lit@^2.0.0 || ^3.0.0":
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.1.4.tgz#03a72e9f0b1f5da317bf49b1ab579a7132e73d7a"
+  integrity sha512-q6qKnKXHy2g1kjBaNfcoLlgbI3+aSOZ9Q4tiGa9bGYXq5RBXxkVTqTIVmP2VWMp29L4GyvCFm8ZQ2o56eUAMyA==
+  dependencies:
+    "@lit/reactive-element" "^2.0.4"
+    lit-element "^4.0.4"
+    lit-html "^3.1.2"
+
+lit@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.2.1.tgz#d6dd15eac20db3a098e81e2c85f70a751ff55592"
+  integrity sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==
+  dependencies:
+    "@lit/reactive-element" "^2.0.4"
+    lit-element "^4.1.0"
+    lit-html "^3.2.0"
 
 lodash.camelcase@^4.3.0:
   version "4.3.0"
@@ -2326,17 +2536,22 @@
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
   integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
 
+merge-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
+  integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
+
 merge2@^1.3.0, merge2@^1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
   integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
 
 micromatch@^4.0.4:
-  version "4.0.5"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
-  integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5"
+  integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==
   dependencies:
-    braces "^3.0.2"
+    braces "^3.0.3"
     picomatch "^2.3.1"
 
 mime-db@1.52.0:
@@ -2414,15 +2629,15 @@
   integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
 
 nise@^5.1.1:
-  version "5.1.5"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.5.tgz#f2aef9536280b6c18940e32ba1fbdc770b8964ee"
-  integrity sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==
+  version "5.1.9"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.9.tgz#0cb73b5e4499d738231a473cd89bd8afbb618139"
+  integrity sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==
   dependencies:
-    "@sinonjs/commons" "^2.0.0"
-    "@sinonjs/fake-timers" "^10.0.2"
-    "@sinonjs/text-encoding" "^0.7.1"
-    just-extend "^4.0.2"
-    path-to-regexp "^1.7.0"
+    "@sinonjs/commons" "^3.0.0"
+    "@sinonjs/fake-timers" "^11.2.2"
+    "@sinonjs/text-encoding" "^0.7.2"
+    just-extend "^6.2.0"
+    path-to-regexp "^6.2.1"
 
 node-fetch@2.6.7:
   version "2.6.7"
@@ -2436,10 +2651,17 @@
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
-object-inspect@^1.9.0:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
-  integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
+npm-run-path@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
+  integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
+  dependencies:
+    path-key "^3.0.0"
+
+object-inspect@^1.13.1:
+  version "1.13.2"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff"
+  integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==
 
 on-finished@^2.3.0:
   version "2.4.1"
@@ -2455,7 +2677,7 @@
   dependencies:
     wrappy "1"
 
-onetime@^5.1.0:
+onetime@^5.1.0, onetime@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
   integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
@@ -2476,6 +2698,25 @@
     is-docker "^2.1.1"
     is-wsl "^2.2.0"
 
+p-event@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5"
+  integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==
+  dependencies:
+    p-timeout "^3.1.0"
+
+p-finally@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+  integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==
+
+p-timeout@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
+  integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==
+  dependencies:
+    p-finally "^1.0.0"
+
 parse5@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@@ -2491,17 +2732,20 @@
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
+path-key@^3.0.0, path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
 path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
-path-to-regexp@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
-  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
-  dependencies:
-    isarray "0.0.1"
+path-to-regexp@^6.2.1:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.2.tgz#324377a83e5049cbecadc5554d6a63a9a4866b36"
+  integrity sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==
 
 path-type@^4.0.0:
   version "4.0.0"
@@ -2513,6 +2757,11 @@
   resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
   integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
 
+picocolors@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1"
+  integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==
+
 picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
@@ -2568,11 +2817,11 @@
     ws "8.13.0"
 
 qs@^6.5.2:
-  version "6.11.2"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
-  integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
+  version "6.12.3"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.3.tgz#e43ce03c8521b9c7fd7f1f13e514e5ca37727754"
+  integrity sha512-AWJm14H1vVaO/iNZ4/hO+HyaTehuy9nRqVdkTqlJt0HWvBiBIEXFmb4C0DGeYo3Xes9rrEW+TxHsaigCbN5ICQ==
   dependencies:
-    side-channel "^1.0.4"
+    side-channel "^1.0.6"
 
 queue-microtask@^1.2.2:
   version "1.2.3"
@@ -2672,21 +2921,21 @@
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
 semver@^7.3.4, semver@^7.5.3:
-  version "7.5.4"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
-  integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
-  dependencies:
-    lru-cache "^6.0.0"
+  version "7.6.3"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
+  integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
 
-set-function-length@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.1.1.tgz#4bc39fafb0307224a33e106a7d35ca1218d659ed"
-  integrity sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==
+set-function-length@^1.2.1:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
+  integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
   dependencies:
-    define-data-property "^1.1.1"
-    get-intrinsic "^1.2.1"
+    define-data-property "^1.1.4"
+    es-errors "^1.3.0"
+    function-bind "^1.1.2"
+    get-intrinsic "^1.2.4"
     gopd "^1.0.1"
-    has-property-descriptors "^1.0.0"
+    has-property-descriptors "^1.0.2"
 
 setprototypeof@1.1.0:
   version "1.1.0"
@@ -2698,16 +2947,29 @@
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
   integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
 
-side-channel@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
-  integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
   dependencies:
-    call-bind "^1.0.0"
-    get-intrinsic "^1.0.2"
-    object-inspect "^1.9.0"
+    shebang-regex "^3.0.0"
 
-signal-exit@^3.0.2:
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
+side-channel@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
+  integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==
+  dependencies:
+    call-bind "^1.0.7"
+    es-errors "^1.3.0"
+    get-intrinsic "^1.2.4"
+    object-inspect "^1.13.1"
+
+signal-exit@^3.0.2, signal-exit@^3.0.3:
   version "3.0.7"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
   integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
@@ -2753,11 +3015,6 @@
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
 
-stream-read-all@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/stream-read-all/-/stream-read-all-3.0.1.tgz#60762ae45e61d93ba0978cda7f3913790052ad96"
-  integrity sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==
-
 string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@@ -2781,10 +3038,15 @@
   dependencies:
     ansi-regex "^5.0.1"
 
+strip-final-newline@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
+  integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
+
 style-mod@^4.0.0, style-mod@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.0.tgz#a313a14f4ae8bb4d52878c0053c4327fb787ec09"
-  integrity sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67"
+  integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==
 
 supports-color@^5.3.0:
   version "5.5.0"
@@ -2805,17 +3067,12 @@
   resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
-table-layout@^3.0.0:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-3.0.2.tgz#69c2be44388a5139b48c59cf21e73b488021769a"
-  integrity sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==
+table-layout@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-4.1.1.tgz#0f72965de1a5c0c1419c9ba21cae4e73a2f73a42"
+  integrity sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==
   dependencies:
-    "@75lb/deep-merge" "^1.1.1"
     array-back "^6.2.2"
-    command-line-args "^5.2.1"
-    command-line-usage "^7.0.0"
-    stream-read-all "^3.0.1"
-    typical "^7.1.1"
     wordwrapjs "^5.1.0"
 
 tar-fs@2.1.1:
@@ -2878,11 +3135,16 @@
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
   integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
 
-type-detect@4.0.8, type-detect@^4.0.8:
+type-detect@4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
 
+type-detect@^4.0.8:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c"
+  integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==
+
 type-fest@^0.21.3:
   version "0.21.3"
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
@@ -2907,9 +3169,9 @@
   integrity sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==
 
 ua-parser-js@^1.0.33:
-  version "1.0.37"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
-  integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==
+  version "1.0.38"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.38.tgz#66bb0c4c0e322fe48edfe6d446df6042e62f25e2"
+  integrity sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==
 
 unbzip2-stream@1.4.3:
   version "1.4.3"
@@ -2919,10 +3181,10 @@
     buffer "^5.2.1"
     through "^2.3.8"
 
-undici-types@~5.26.4:
-  version "5.26.5"
-  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
-  integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+undici-types@~6.11.1:
+  version "6.11.1"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.11.1.tgz#432ea6e8efd54a48569705a699e62d8f4981b197"
+  integrity sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==
 
 unpipe@1.0.0:
   version "1.0.0"
@@ -2935,9 +3197,9 @@
   integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
 
 v8-to-istanbul@^9.0.1:
-  version "9.2.0"
-  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad"
-  integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==
+  version "9.3.0"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175"
+  integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.12"
     "@types/istanbul-lib-coverage" "^2.0.1"
@@ -2979,6 +3241,13 @@
     tr46 "~0.0.3"
     webidl-conversions "^3.0.0"
 
+which@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+  dependencies:
+    isexe "^2.0.0"
+
 wordwrapjs@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.0.tgz#4c4d20446dcc670b14fa115ef4f8fd9947af2b3a"
@@ -3013,9 +3282,9 @@
   integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
 
 ws@^7.4.2:
-  version "7.5.9"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
-  integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
+  version "7.5.10"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
+  integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
 
 y18n@^5.0.5:
   version "5.0.8"
@@ -3054,6 +3323,6 @@
     fd-slicer "~1.1.0"
 
 ylru@^1.2.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
-  integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.4.0.tgz#0cf0aa57e9c24f8a2cbde0cc1ca2c9592ac4e0f6"
+  integrity sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 914b099..c2afebb 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -177,6 +177,13 @@
   checkLink?: string;
 
   /**
+   * Indicates that the check run is powered by Aritificial Intelligence. This
+   * allows the UI to add special treatment, e.g. an icon to be added to the
+   * check name. Defaults to `false`.
+   */
+  isAiPowered?: boolean;
+
+  /**
    * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
    *            (see actions) and for indicating that a check was not run at a
    *            later attempt. Cannot contain results.
@@ -269,6 +276,12 @@
   callback: ActionCallback;
 }
 
+/**
+ * Action names that get special UI treatment.
+ */
+export const USEFUL = 'useful';
+export const NOT_USEFUL = 'not-useful';
+
 export type ActionCallback = (
   change: number,
   patchset: number,
@@ -427,6 +440,9 @@
    * per result, then further actions are put into an overflow menu. Sort order
    * is defined by the data provider.
    *
+   * The actions with the names 'useful' and 'not-useful' will get special UI
+   * treatment (clickable thumbs-up and thumbs-down icons).
+   *
    * Examples:
    * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,
    * Make blocking, Downgrade severity.
@@ -514,4 +530,5 @@
   REPORT_BUG = 'report_bug',
   CODE = 'code',
   FILE_PRESENT = 'file_present',
+  VIEW_TIMELINE = 'view_timeline',
 }
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 092375a..945afe4 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -255,11 +255,6 @@
   show_newline_warning_left?: boolean;
   show_newline_warning_right?: boolean;
   use_new_image_diff_ui?: boolean;
-  /**
-   * Temporary flag for switching over to a simplified version of the diff
-   * processor.
-   */
-  use_simplified_processor?: boolean;
 }
 
 /**
@@ -311,12 +306,12 @@
   code_range: LineRange;
 }
 
-export interface FileRange {
+export declare interface FileRange {
   basePath?: string;
   path: string;
 }
 
-export interface PatchRange {
+export declare interface PatchRange {
   patchNum: RevisionPatchSetNum;
   basePatchNum: BasePatchSetNum;
 }
@@ -557,3 +552,13 @@
     intentionalMove?: boolean
   ): void;
 }
+
+/**
+ * Represents a list of ranges in a diff that should be focused.
+ *
+ * This is used to collapse diff chunks that are not in focus.
+ */
+export declare interface DiffRangesToFocus {
+  left: {start: number; end: number}[];
+  right: {start: number; end: number}[];
+}
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index af481fd..d6425be 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -31,3 +31,40 @@
     };
   }
 }
+
+/** <gr-textarea> input event */
+export declare interface InputEventDetail {
+  value: string;
+}
+
+/** <gr-textarea> event for current cursor position */
+export declare interface CursorPositionChangeEventDetail {
+  position: number;
+}
+
+/** <gr-textarea> event when showing a hint */
+export declare interface HintShownEventDetail {
+  hint: string;
+  oldValue: string;
+}
+
+/** <gr-textarea> event when a hint was dismissed */
+export declare interface HintDismissedEventDetail {
+  hint: string;
+}
+
+/** <gr-textarea> event when a hint was applied */
+export declare interface HintAppliedEventDetail {
+  hint: string;
+  oldValue: string;
+}
+
+/** <gr-textarea> interface that external users can rely on */
+export declare interface GrTextarea extends HTMLElement {
+  value?: string;
+  nativeElement?: HTMLElement;
+  placeholder?: string;
+  placeholderHint?: string;
+  hint?: string;
+  setRangeText: (replacement: string, start: number, end: number) => void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 684429a..c595add 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -9,6 +9,7 @@
 import {ChangeReplyPluginApi} from './change-reply';
 import {ChecksPluginApi} from './checks';
 import {EventHelperPluginApi} from './event-helper';
+import {PluginElement} from './hook';
 import {PopupPluginApi} from './popup';
 import {ReportingPluginApi} from './reporting';
 import {ChangeActionsPluginApi} from './change-actions';
@@ -34,6 +35,7 @@
   POST_REVERT = 'postrevert',
   ADMIN_MENU_LINKS = 'admin-menu-links',
   SHOW_DIFF = 'showdiff',
+  REPLY_SENT = 'replysent',
 }
 
 export declare interface PluginApi {
@@ -62,7 +64,7 @@
   suggestions(): SuggestionsPluginApi;
   eventHelper(element: Node): EventHelperPluginApi;
   getPluginName(): string;
-  hook<T extends HTMLElement>(
+  hook<T extends PluginElement>(
     endpointName: string,
     opt_options?: RegisterOptions
   ): HookApi<T>;
@@ -71,12 +73,12 @@
   popup(): Promise<PopupPluginApi>;
   popup(moduleName: string): Promise<PopupPluginApi>;
   popup(moduleName?: string): Promise<PopupPluginApi | null>;
-  registerCustomComponent<T extends HTMLElement>(
+  registerCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi<T>;
-  registerDynamicCustomComponent<T extends HTMLElement>(
+  registerDynamicCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 997b8fe8..382a043 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -174,6 +174,7 @@
 export enum RevisionKind {
   REWORK = 'REWORK',
   TRIVIAL_REBASE = 'TRIVIAL_REBASE',
+  TRIVIAL_REBASE_WITH_MESSAGE_UPDATE = 'TRIVIAL_REBASE_WITH_MESSAGE_UPDATE',
   MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
   NO_CODE_CHANGE = 'NO_CODE_CHANGE',
   NO_CHANGE = 'NO_CHANGE',
@@ -395,6 +396,7 @@
   pending_reviewers?: AccountInfo[];
   reviewer_updates?: ReviewerUpdateInfo[];
   messages?: ChangeMessageInfo[];
+  current_revision_number: PatchSetNumber;
   current_revision?: CommitId;
   revisions?: {[revisionId: string]: RevisionInfo};
   tracking_ids?: TrackingIdInfo[];
@@ -520,7 +522,7 @@
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
-export interface CommentInfo {
+export declare interface CommentInfo {
   id: UrlEncodedCommentId;
   updated: Timestamp;
   // TODO(TS): Make this required. Every comment must have patch_set set.
@@ -611,7 +613,7 @@
  * The ContextLine entity contains the line number and line text of a single line of the source file content.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
  */
-export interface ContextLine {
+export declare interface ContextLine {
   line_number: number;
   context_line: string;
 }
@@ -669,6 +671,16 @@
 export type EmailAddress = BrandType<string, '_emailAddress'>;
 
 /**
+ * The EmailInfo entity contains information about an email address of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#email-info
+ */
+export declare interface EmailInfo {
+  email: EmailAddress;
+  preferred?: boolean;
+  pending_confirmation?: boolean;
+}
+
+/**
  * The FetchInfo entity contains information about how to fetch a patchset via
  * a certain protocol.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
@@ -706,6 +718,7 @@
   doc_search: boolean;
   doc_url?: string;
   edit_gpg_keys?: boolean;
+  project_state_predicate_enabled: boolean;
   report_bug_url?: string;
   // The following property is missed in doc
   primary_weblink_name?: string;
@@ -1073,8 +1086,6 @@
   change: ChangeConfigInfo;
   download: DownloadInfo;
   gerrit: GerritInfo;
-  // docs mentions index property, but it doesn't exists in Java class
-  // index: IndexConfigInfo;
   note_db_enabled?: boolean;
   plugin: PluginConfigInfo;
   receive?: ReceiveInfo;
@@ -1083,6 +1094,7 @@
   user: UserConfigInfo;
   default_theme?: string;
   submit_requirement_dashboard_columns?: string[];
+  metadata?: MetadataInfo[];
 }
 
 /**
@@ -1093,6 +1105,17 @@
  */
 export type SshdInfo = {};
 
+/**
+ * The MetadataInfo entity contains contains metadata provided by plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#metadata-info
+ */
+export declare interface MetadataInfo {
+  name: string;
+  value?: string;
+  description?: string;
+  web_links?: WebLinkInfo[];
+}
+
 // Timestamps are given in UTC and have the format
 // "'yyyy-mm-dd hh:mm:ss.fffffffff'"
 // where "'ffffffffff'" represents nanoseconds.
@@ -1298,7 +1321,7 @@
  * The FixSuggestionInfo entity represents a suggested fix
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-suggestion-info
  */
-export interface FixSuggestionInfoInput {
+export declare interface FixSuggestionInfoInput {
   description: string;
   replacements: FixReplacementInfo[];
 }
@@ -1307,7 +1330,7 @@
  * The FixReplacementInfo entity describes how the content of a file should be replaced by another content
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-replacement-info
  */
-export interface FixReplacementInfo {
+export declare interface FixReplacementInfo {
   path: string;
   range: CommentRange;
   replacement: string;
@@ -1315,8 +1338,9 @@
 // The UUID of the suggested fix.
 export type FixId = BrandType<string, '_fixId'>;
 
-export interface FixSuggestionInfo extends FixSuggestionInfoInput {
+export declare interface FixSuggestionInfo extends FixSuggestionInfoInput {
   fix_id: FixId;
   description: string;
   replacements: FixReplacementInfo[];
+  log_probability?: number;
 }
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
index 3bc310c..1aa4ebe 100644
--- a/polygerrit-ui/app/api/suggestions.ts
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -27,7 +27,21 @@
   lineNumber?: number;
 }
 
+export declare interface AutocompleteCommentRequest {
+  id: string;
+  commentText: string;
+  changeInfo: ChangeInfo;
+  patchsetNumber: RevisionPatchSetNum;
+  filePath: string;
+  range?: CommentRange;
+  lineNumber?: number;
+}
+
 export declare interface SuggestionsProvider {
+  autocompleteComment?(
+    req: AutocompleteCommentRequest
+  ): Promise<AutocompleteCommentResponse>;
+
   /**
    * Gerrit calls these methods when ...
    * - ... user types a comment draft
@@ -35,11 +49,33 @@
   suggestCode?(commentData: SuggestCodeRequest): Promise<SuggestCodeResponse>;
   suggestFix?(commentData: SuggestCodeRequest): Promise<SuggestedFixResponse>;
   /**
+   * Gets the title to display on the fix suggestion preview.
+   *
+   * @param fix_suggestions A list of suggested fixes.
+   * @return The title string or empty to use the default title.
+   */
+  getFixSuggestionTitle?(fix_suggestions?: FixSuggestionInfo[]): string;
+  /**
+   * Gets a link to documentation for icon help next to title
+   *
+   * @param fix_suggestions A list of suggested fixes.
+   * @return The documentation URL string or empty to use the default link to
+   * gerrit documentation about fix suggestions.
+   */
+  getDocumentationLink?(fix_suggestions?: FixSuggestionInfo[]): string;
+  /**
    * List of supported file extensions. If undefined, all file extensions supported.
    */
   supportedFileExtensions?: string[];
 }
 
+export declare interface AutocompleteCommentResponse {
+  responseCode: ResponseCode;
+  completion?: string;
+  modelVersion?: string;
+  outcome?: number;
+}
+
 export declare interface SuggestCodeResponse {
   responseCode: ResponseCode;
   suggestions: Suggestion[];
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 0fa58f4..b21663a 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -264,6 +264,7 @@
     default_base_for_merges: DefaultBase.AUTO_MERGE,
     allow_browser_notifications: false,
     allow_suggest_code_while_commenting: true,
+    allow_autocompleting_comments: true,
     diff_page_sidebar: 'NONE',
   };
 }
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index dded0c6..98dd592 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -90,8 +90,6 @@
   CHECKS_LOAD = 'ChecksLoad',
   // Webvitals - Cumulative Layout Shift (CLS): measures visual stability
   CLS = 'CLS',
-  // WebVitals - First Input Delay (FID): measures interactivity
-  FID = 'FID',
   // WebVitals - Largest Contentful Paint (LCP): measures loading performance.
   LCP = 'LCP',
   // WebVitals - Interaction to Next Paint (INP): measures responsiveness
@@ -102,6 +100,8 @@
   APPLY_FIX_LOAD = 'ApplyFixLoad',
   // Time to copy target to clipboard
   COPY_TO_CLIPBOARD = 'CopyToClipboard',
+  // Time to autocomplete a comment
+  COMMENT_COMPLETION = 'CommentCompletion',
 }
 
 export enum Interaction {
@@ -131,6 +131,8 @@
   CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
   CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
   CHECKS_STATS = 'checks-stats',
+  COMMENTS_STATS = 'comments-stats',
+  THREADS_STATS = 'threads-stats',
   CHANGE_ACTION_FIRED = 'change-action-fired',
   BUTTON_CLICK = 'button-click',
   LINK_CLICK = 'link-click',
@@ -146,6 +148,9 @@
   GENERATE_SUGGESTION_ENABLED = 'generate_suggestion_enabled',
   // User disabled generating suggestions
   GENERATE_SUGGESTION_DISABLED = 'generate_suggestion_disabled',
+  GENERATE_SUGGESTION_EDITED = 'generate_suggestion_edited',
+  GENERATE_SUGGESTION_COLLAPSED = 'generate_suggestion_collapsed',
+  GENERATE_SUGGESTION_EXPANDED = 'generate_suggestion_expanded',
   START_REVIEW = 'start-review',
   CODE_REVIEW_APPROVAL = 'code-review-approval',
   FILE_LIST_DIFF_COLLAPSED = 'file-list-diff-collapsed',
@@ -155,4 +160,9 @@
   // The very first reporting event with `ChangeId` set when visiting a change
   // related page. Can be used as a starting point for user journeys.
   CHANGE_ID_CHANGED = 'change-id-changed',
+
+  COMMENT_COMPLETION_SUGGESTION_SHOWN = 'comment-completion-suggestion-shown',
+  COMMENT_COMPLETION_SUGGESTION_ACCEPTED = 'comment-completion-suggestion-accepted',
+  COMMENT_COMPLETION_SAVE_DRAFT = 'comment-completion-save-draft',
+  COMMENT_COMPLETION_SUGGESTION_FETCHED = 'comment-completion-suggestion-fetched',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 2c397e0..60bcf8a 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-access-section';
 import {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
index fe5aa22..750beab 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-admin-group-list';
 import {GrAdminGroupList} from './gr-admin-group-list';
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 21e032b..58827fb 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -17,6 +17,7 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
+import '../gr-server-info/gr-server-info';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   AccountDetailInfo,
@@ -213,6 +214,7 @@
       ${this.renderGroupMembers()} ${this.renderGroupAuditLog()}
       ${this.renderRepoDetailList()} ${this.renderRepoCommands()}
       ${this.renderRepoAccess()} ${this.renderRepoDashboards()}
+      ${this.renderServerInfo()}
     `;
   }
 
@@ -447,6 +449,18 @@
     `;
   }
 
+  private renderServerInfo() {
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.SERVER_INFO)
+      return nothing;
+
+    return html`
+      <div class="main table">
+        <gr-server-info class="table"></gr-server-info>
+      </div>
+    `;
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('groupId')) {
       this.computeGroupName();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index d184f35..34e8d24 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-admin-view';
 import {AdminSubsectionLink, GrAdminView} from './gr-admin-view';
@@ -84,7 +85,7 @@
       Promise.resolve(createAdminCapabilities())
     );
     await element.reload();
-    assert.equal(element.filteredLinks!.length, 3);
+    assert.equal(element.filteredLinks!.length, 4);
 
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
@@ -98,7 +99,7 @@
 
   test('filteredLinks non admin authenticated', async () => {
     await element.reload();
-    assert.equal(element.filteredLinks!.length, 2);
+    assert.equal(element.filteredLinks!.length, 3);
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
     // Groups
@@ -162,7 +163,7 @@
     );
     await element.reload();
     await element.updateComplete;
-    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 3);
+    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 4);
     assert.equal(
       queryAndAssert<HTMLSpanElement>(element, '.breadcrumbText').innerText,
       'Test Repo'
@@ -189,7 +190,7 @@
     );
     await element.reload();
     await element.updateComplete;
-    assert.equal(element.filteredLinks!.length, 3);
+    assert.equal(element.filteredLinks!.length, 4);
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
     // Groups
@@ -385,6 +386,12 @@
         url: '/admin/plugins',
         view: 'gr-plugin-list' as GerritView,
       },
+      {
+        name: 'Server Info',
+        section: 'Server Info',
+        url: '/admin/server-info',
+        view: 'gr-server-info' as GerritView,
+      },
     ];
     const expectedSubsectionLinks = [
       {
@@ -532,6 +539,11 @@
                   Plugins
                 </a>
               </li>
+              <li class="sectionTitle">
+                <a class="title" href="/admin/server-info" rel="noopener">
+                  Server Info
+                </a>
+              </li>
             </ul>
           </gr-page-nav>
           <div class="main table">
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
index d4b5f03..a30b0df 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-confirm-delete-item-dialog';
 import {GrConfirmDeleteItemDialog} from './gr-confirm-delete-item-dialog';
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index 87916b6..d26df6e 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-create-change-dialog';
 import {GrCreateChangeDialog} from './gr-create-change-dialog';
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
index e2da5d7..885e1cf 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-create-file-edit-dialog';
 import {createChange} from '../../../test/test-data-generators';
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
index c5fbde3..407d015 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-create-group-dialog';
 import {GrCreateGroupDialog} from './gr-create-group-dialog';
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index c16a108..5dc4e42 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-group-members';
 import {GrGroupMembers, ItemType} from './gr-group-members';
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 8ee7669..754a233 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -7,7 +7,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import {
   AutocompleteSuggestion,
   AutocompleteQuery,
@@ -222,7 +222,7 @@
       </h3>
       <fieldset>
         <div>
-          <gr-textarea
+          <gr-suggestion-textarea
             class="description"
             autocomplete="on"
             rows="4"
@@ -230,7 +230,7 @@
             ?disabled=${this.computeGroupDisabled()}
             .text=${this.groupConfig?.description ?? ''}
             @text-changed=${this.handleDescriptionTextChanged}
-          ></gr-textarea>
+          ></gr-suggestion-textarea>
         </div>
         <span class="value">
           <gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 256c6a9..aaf8dfc 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-group';
 import {GrGroup} from './gr-group';
@@ -97,14 +98,14 @@
                 <h3 class="heading-3">Description</h3>
                 <fieldset>
                   <div>
-                    <gr-textarea
+                    <gr-suggestion-textarea
                       autocomplete="on"
                       class="description monospace"
                       disabled=""
                       monospace=""
                       rows="4"
                     >
-                    </gr-textarea>
+                    </gr-suggestion-textarea>
                   </div>
                   <span class="value">
                     <gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 0b49a87..933fc96 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -353,8 +353,16 @@
     if (!this.permission) {
       return;
     }
-    this.permission.value.modified = true;
-    this.permission.value.exclusive = (e.target as HTMLInputElement).checked;
+    // Update entire permission object to trigger a re-render since permission
+    // is marked as @property
+    this.permission = {
+      ...this.permission,
+      value: {
+        ...this.permission.value,
+        modified: true,
+        exclusive: (e.target as HTMLInputElement).checked,
+      },
+    };
     // Allows overall access page to know a change has been made.
     fire(this, 'access-modified', {});
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
index 46f6ac4..2909694 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-permission';
 import {GrPermission} from './gr-permission';
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
index 672d58e..fea0d58 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {ConfigParameterInfoType} from '../../../constants/constants';
 import '../../../test/common-test-setup';
 import './gr-plugin-config-array-editor';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 615528c..ff87849 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -74,6 +74,8 @@
   // private but used in test
   @state() canUpload?: boolean = false; // restAPI can return undefined
 
+  @state() disableSaveWithoutReview = true;
+
   // private but used in test
   @state() inheritFromFilter?: RepoName;
 
@@ -238,7 +240,7 @@
                 ? 'invisible'
                 : ''}
               primary
-              ?disabled=${!this.modified}
+              ?disabled=${!this.modified || this.disableSaveWithoutReview}
               @click=${this.handleSave}
               >Save</gr-button
             >
@@ -343,6 +345,7 @@
         this.groups = res.groups;
         this.weblinks = res.config_web_links || [];
         this.canUpload = res.can_upload;
+        this.disableSaveWithoutReview = !!res.require_change_for_config_update;
         this.ownerOf = res.owner_of || [];
         return toSortedPermissionsArray(this.local);
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index 467857d..c321abf 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-repo-access';
 import {GrRepoAccess} from './gr-repo-access';
@@ -157,12 +158,13 @@
                 Edit
               </gr-button>
               <gr-button
-                aria-disabled="false"
+                aria-disabled="true"
+                disabled=""
                 class="invisible"
                 id="saveBtn"
                 primary=""
                 role="button"
-                tabindex="0"
+                tabindex="-1"
               >
                 Save
               </gr-button>
@@ -1445,9 +1447,18 @@
       );
 
       element.repo = 'test-repo' as RepoName;
+      await element.updateComplete;
       sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
-
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
       element.modified = true;
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        false
+      );
       queryAndAssert<GrButton>(element, '#saveBtn').click();
       await element.updateComplete;
       assert.equal(
@@ -1460,6 +1471,58 @@
       assert.isTrue(setUrlStub.notCalled);
     });
 
+    test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      stubRestApi('getRepoAccessRights').returns(
+        Promise.resolve(
+          JSON.parse(
+            JSON.stringify({
+              ...accessRes,
+              require_change_for_config_update: true,
+            })
+          )
+        )
+      );
+
+      element.repo = 'test-repo' as RepoName;
+      await element.updateComplete;
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
+      element.modified = true;
+      await element.updateComplete;
+      assert.equal(element.disableSaveWithoutReview, true);
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+        true
+      );
+    });
+
     test('handleSaveForReview', async () => {
       const repoAccessInput = {
         add: {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 1a0eeea..7edfe2b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -208,7 +208,7 @@
       return;
 
     return html`
-      <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
+      <h2 class="heading-2">${this.repoConfig?.actions['gc']?.label}</h2>
       <gr-button
         title=${this.repoConfig?.actions['gc']?.title || ''}
         ?loading=${this.runningGC}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
index af2831a..deec717 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-repo-commands';
 import {GrRepoCommands} from './gr-repo-commands';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index 5e25b33..3dd5e69 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-repo-detail-list';
 import {GrRepoDetailList} from './gr-repo-detail-list';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 36674f5..d7377aa 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-repo-list';
 import {GrRepoList} from './gr-repo-list';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
index 3dc6f1e..1691b88 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-repo-plugin-config';
 import {GrRepoPluginConfig} from './gr-repo-plugin-config';
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 90277534..6e1aba7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -9,8 +9,9 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-download-commands/gr-download-commands';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   ConfigInfo,
   RepoName,
@@ -39,11 +40,14 @@
 import {deepClone} from '../../../utils/deep-util';
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {createChangeUrl} from '../../../models/views/change';
 import {when} from 'lit/directives/when.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {createSearchUrl} from '../../../models/views/search';
 import {userModelToken} from '../../../models/user/user-model';
 import {resolve} from '../../../models/dependency';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const STATES = {
   active: {value: RepoState.ACTIVE, label: 'Active'},
@@ -101,6 +105,11 @@
   // private but used in test
   @state() readOnly = true;
 
+  @state() showSaveForReviewButton = false;
+
+  // private but used in test
+  @state() disableSaveWithoutReview = true;
+
   @state() private states = Object.values(STATES);
 
   @state() private originalConfig?: ConfigInfo;
@@ -114,10 +123,14 @@
 
   @state() private pluginConfigChanged = false;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   constructor() {
     super();
     subscribe(
@@ -195,10 +208,20 @@
                 ${this.renderDescription()} ${this.renderRepoOptions()}
                 ${this.renderPluginConfig()}
                 <gr-button
-                  ?disabled=${this.readOnly || !configChanged}
+                  id="saveBtn"
+                  ?disabled=${this.readOnly ||
+                  this.disableSaveWithoutReview ||
+                  !configChanged}
                   @click=${this.handleSaveRepoConfig}
                   >Save changes</gr-button
                 >
+                <gr-button
+                  id="saveReviewBtn"
+                  ?disabled=${this.readOnly || !configChanged}
+                  ?hidden=${!this.showSaveForReviewButton}
+                  @click=${this.handleSaveRepoConfigForReview}
+                  >Save for review</gr-button
+                >
               </fieldset>
               <gr-endpoint-decorator name="repo-config">
                 <gr-endpoint-param
@@ -244,7 +267,7 @@
     return html`
       <h3 id="Description" class="heading-3">Description</h3>
       <fieldset>
-        <gr-textarea
+        <gr-suggestion-textarea
           id="descriptionInput"
           class="description"
           autocomplete="on"
@@ -254,7 +277,7 @@
           ?disabled=${this.readOnly}
           .text=${this.repoConfig.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
-        ></gr-textarea>
+        ></gr-suggestion-textarea>
       </fieldset>
     `;
   }
@@ -781,6 +804,11 @@
 
             // If the user is not an owner, is_owner is not a property.
             this.readOnly = !access[repo]?.is_owner;
+            this.showSaveForReviewButton = this.flagsService.isEnabled(
+              KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+            );
+            this.disableSaveWithoutReview =
+              !!access[repo]?.require_change_for_config_update;
           });
         }
       })
@@ -815,7 +843,7 @@
         config.max_object_size_limit.configured_value = '';
       }
       this.repoConfig = config;
-      this.originalConfig = deepClone(config) as ConfigInfo;
+      this.originalConfig = deepClone<ConfigInfo>(config);
       this.loading = false;
     };
     promises.push(repoConfigHelper());
@@ -920,11 +948,37 @@
       this.repo,
       this.formatRepoConfigForSave(this.repoConfig)
     );
-    this.originalConfig = deepClone(this.repoConfig) as ConfigInfo;
+    this.originalConfig = deepClone<ConfigInfo>(this.repoConfig);
     this.pluginConfigChanged = false;
     return;
   }
 
+  async handleSaveRepoConfigForReview(e: Event) {
+    if (!this.repoConfig || !this.repo)
+      return Promise.reject(new Error('undefined repoConfig or repo'));
+    const button = e && (e.target as GrButton);
+    if (button) {
+      button.loading = true;
+    }
+
+    return this.restApiService
+      .saveRepoConfigForReview(
+        this.repo,
+        this.formatRepoConfigForSave(this.repoConfig)
+      )
+      .then(change => {
+        // Don't navigate on server error.
+        if (change) {
+          this.getNavigation().setUrl(createChangeUrl({change}));
+        }
+      })
+      .finally(() => {
+        if (button) {
+          button.loading = false;
+        }
+      });
+  }
+
   private isEdited(
     original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
     repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 4deb99a..e33fd04 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -3,9 +3,12 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-repo';
 import {GrRepo} from './gr-repo';
+import {createChange} from '../../../test/test-data-generators';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {mockPromise} from '../../../test/test-utils';
 import {
   addListenerForTest,
@@ -17,6 +20,7 @@
   createInheritedBoolean,
   createServerInfo,
 } from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
 import {
   ConfigInfo,
   GitRef,
@@ -25,6 +29,7 @@
   InheritedBooleanInfo,
   MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
+  ProjectAccessInfo,
   RepoAccessGroups,
   RepoAccessInfoMap,
   RepoName,
@@ -32,6 +37,7 @@
 import {
   ConfigParameterInfoType,
   InheritedBooleanInfoConfiguredValue,
+  PermissionAction,
   RepoState,
   SubmitType,
 } from '../../../constants/constants';
@@ -42,9 +48,12 @@
 import {PageErrorEvent} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
 import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {ChangeInfo} from '../../../api/rest-api';
 
 suite('gr-repo tests', () => {
   let element: GrRepo;
@@ -199,7 +208,7 @@
             <fieldset>
               <h3 class="heading-3" id="Description">Description</h3>
               <fieldset>
-                <gr-textarea
+                <gr-suggestion-textarea
                   autocomplete="on"
                   class="description monospace"
                   disabled=""
@@ -208,7 +217,7 @@
                   placeholder="<Insert repo description here>"
                   rows="4"
                 >
-                </gr-textarea>
+                </gr-suggestion-textarea>
               </fieldset>
               <h3 class="heading-3" id="Options">Repository Options</h3>
               <fieldset id="options">
@@ -375,6 +384,7 @@
                 </section>
               </fieldset>
               <gr-button
+                id="saveBtn"
                 aria-disabled="true"
                 disabled=""
                 role="button"
@@ -382,6 +392,16 @@
               >
                 Save changes
               </gr-button>
+              <gr-button
+                id="saveReviewBtn"
+                aria-disabled="true"
+                disabled=""
+                hidden=""
+                role="button"
+                tabindex="-1"
+              >
+                Save for review
+              </gr-button>
             </fieldset>
             <gr-endpoint-decorator name="repo-config">
               <gr-endpoint-param name="repoName"> </gr-endpoint-param>
@@ -633,31 +653,35 @@
   });
 
   suite('admin', () => {
+    const testRepoAccess: ProjectAccessInfo = {
+      revision: 'xxxx',
+      local: {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {xxx: {action: PermissionAction.ALLOW, force: false}},
+            },
+          },
+        },
+      },
+      is_owner: true,
+      owner_of: ['refs/*'] as GitRef[],
+      groups: {
+        xxxx: {
+          id: 'xxxx' as GroupId,
+          url: 'test',
+          name: 'test' as GroupName,
+        },
+      } as RepoAccessGroups,
+      config_web_links: [{name: 'gitiles', url: 'test'}],
+    };
+    let getRepoAccessStub: sinon.SinonStub;
     setup(() => {
       element.repo = REPO as RepoName;
       loggedInStub.returns(Promise.resolve(true));
-      stubRestApi('getRepoAccess').callsFake(() =>
+      getRepoAccessStub = stubRestApi('getRepoAccess').callsFake(() =>
         Promise.resolve({
-          'test-repo': {
-            revision: 'xxxx',
-            local: {
-              'refs/*': {
-                permissions: {
-                  owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
-                },
-              },
-            },
-            is_owner: true,
-            owner_of: ['refs/*'] as GitRef[],
-            groups: {
-              xxxx: {
-                id: 'xxxx' as GroupId,
-                url: 'test',
-                name: 'test' as GroupName,
-              },
-            } as RepoAccessGroups,
-            config_web_links: [{name: 'gitiles', url: 'test'}],
-          },
+          'test-repo': testRepoAccess,
         } as RepoAccessInfoMap)
       );
     });
@@ -720,7 +744,7 @@
 
       await element.loadRepo();
 
-      const button = queryAll<GrButton>(element, 'gr-button')[2];
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
@@ -728,7 +752,7 @@
           '#Title'
         ).classList.contains('edited')
       );
-      queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+      queryAndAssert<GrSuggestionTextarea>(element, '#descriptionInput').text =
         configInputObj.description;
       queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
         configInputObj.state;
@@ -800,5 +824,76 @@
         saveStub.lastCall.calledWithExactly(REPO as RepoName, configInputObj)
       );
     });
+
+    test('saveReviewBtn visible when experiment is enabled', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      await element.loadRepo();
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(
+        element,
+        'gr-button#saveReviewBtn'
+      );
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+
+    test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      getRepoAccessStub.callsFake(() =>
+        Promise.resolve({
+          'test-repo': {
+            ...testRepoAccess,
+            require_change_for_config_update: true,
+          },
+        } as RepoAccessInfoMap)
+      );
+      await element.loadRepo();
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+      assert.isTrue(button.hasAttribute('disabled'));
+    });
+
+    test('saveReviewBtn', async () => {
+      const flagsService = getAppContext().flagsService;
+      sinon
+        .stub(flagsService, 'isEnabled')
+        .callsFake(
+          id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+        );
+      let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
+      const saveForReviewStub = stubRestApi('saveRepoConfigForReview').returns(
+        new Promise(r => (resolver = r))
+      );
+      resolver!(createChange());
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      await element.loadRepo();
+      await element.updateComplete;
+      const input = queryAndAssert<GrSuggestionTextarea>(
+        element,
+        '#descriptionInput'
+      );
+      input.text = 'New description';
+      await input.updateComplete;
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+      assert.isFalse(button.hasAttribute('disabled'));
+      queryAndAssert<GrButton>(element, 'gr-button#saveReviewBtn').click();
+      await element.updateComplete;
+      assert.isTrue(saveForReviewStub.called);
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(
+        setUrlStub.lastCall.args?.[0]?.includes(`${createChange()._number}`)
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
index 8066289..650876d 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-rule-editor';
 import {GrRuleEditor} from './gr-rule-editor';
diff --git a/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts b/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts
new file mode 100644
index 0000000..836a5eb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../shared/gr-weblink/gr-weblink';
+import {MetadataInfo, ServerInfo, WebLinkInfo} from '../../../types/common';
+import {configModelToken} from '../../../models/config/config-model';
+import {customElement, state} from 'lit/decorators.js';
+import {css, html, LitElement} from 'lit';
+import {fireTitleChange} from '../../../utils/event-util';
+import {map} from 'lit/directives/map.js';
+import {resolve} from '../../../models/dependency';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {tableStyles} from '../../../styles/gr-table-styles';
+
+@customElement('gr-server-info')
+export class GrServerInfo extends LitElement {
+  @state() serverInfo?: ServerInfo;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverInfo => {
+        this.serverInfo = serverInfo;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        .genericList tr td:last-of-type {
+          text-align: left;
+        }
+        .genericList tr th:last-of-type {
+          text-align: left;
+        }
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange('Server Info');
+  }
+
+  override render() {
+    return html`
+      <main class="gr-form-styles read-only">
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="metadataName topHeader">Name</th>
+              <th class="metadataValue topHeader">Value</th>
+              <th class="metadataWebLinks topHeader">Links</th>
+              <th class="metadataDescription topHeader">Description</th>
+            </tr>
+          </tbody>
+          ${this.renderServerInfoTable()}
+        </table>
+      </main>
+    `;
+  }
+
+  private renderServerInfoTable() {
+    return html`
+      <tbody>
+        ${map(this.getServerInfoAsMetadataInfos(), metadata =>
+          this.renderServerInfo(metadata)
+        )}
+      </tbody>
+    `;
+  }
+
+  private renderServerInfo(metadata: MetadataInfo) {
+    return html`
+      <tr class="table">
+        <td class="metadataName">${metadata.name}</td>
+        <td class="metadataValue">
+          ${metadata.value
+            ? metadata.value
+            : html`<span class="placeholder">--</span>`}
+        </td>
+        <td class="metadataWebLinks">
+          ${metadata.web_links
+            ? map(metadata.web_links, webLink => this.renderWebLink(webLink))
+            : ''}
+        </td>
+        <td class="metadataDescription">
+          ${metadata.description ? metadata.description : ''}
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderWebLink(info: WebLinkInfo) {
+    return html`<p><gr-weblink imageAndText .info=${info}></gr-weblink></p>`;
+  }
+
+  private getServerInfoAsMetadataInfos() {
+    let metadataList = new Array<MetadataInfo>();
+
+    const accountsVisibilityMetadata = this.createAccountVisibilityMetadata();
+    if (accountsVisibilityMetadata) {
+      metadataList.push(accountsVisibilityMetadata);
+    }
+
+    if (this.serverInfo?.metadata) {
+      metadataList = metadataList.concat(this.serverInfo.metadata);
+    }
+
+    return metadataList;
+  }
+
+  private createAccountVisibilityMetadata(): MetadataInfo | undefined {
+    if (this.serverInfo?.accounts?.visibility) {
+      const accountsVisibilityMetadata = {
+        name: 'accounts.visibility',
+        value: this.serverInfo.accounts.visibility,
+        description:
+          "Controls visibility of other users' dashboard pages and completion suggestions to web users.",
+        web_links: new Array<WebLinkInfo>(),
+      };
+      if (this.serverInfo?.gerrit?.doc_url) {
+        const docWebLink = {
+          name: 'Documentation',
+          url:
+            this.serverInfo.gerrit.doc_url +
+            'config-gerrit.html#accounts.visibility',
+        };
+        accountsVisibilityMetadata.web_links.push(docWebLink);
+      }
+      return accountsVisibilityMetadata;
+    }
+    return undefined;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-server-info': GrServerInfo;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index 15f022b..b91a0a0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {createChange} from '../../../test/test-data-generators';
 import {
   NumericChangeId,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index c65371b..df1b297 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {GrChangeListBulkVoteFlow} from './gr-change-list-bulk-vote-flow';
 import {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
index 94ca74a..859a2ba 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {IronDropdownElement} from '@polymer/iron-dropdown';
 import {SinonStubbedMember} from 'sinon';
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 4f5a888..2722efe 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
 import {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index d2f5fa2..c494448 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {SinonStubbedMember} from 'sinon';
 import {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 91770ca..0799e03 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -172,7 +172,7 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('changeSection')) {
+    if (changedProperties.has('changeSection') && this.isLoggedIn) {
       // In case the list of changes is updated due to auto reloading, we want
       // to ensure the model removes any stale change that is not a part of the
       // new section changes.
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index 694d953..75a5b5c8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {
   GrChangeListSection,
   computeLabelShortcut,
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
index 2934772..087b8a8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {IronDropdownElement} from '@polymer/iron-dropdown';
 import {SinonStubbedMember} from 'sinon';
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index 93a530d..d69eac8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-change-list-view';
 import {GrChangeListView} from './gr-change-list-view';
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index c53f813..30d3455 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-change-list';
 import {GrChangeList, computeRelativeIndex} from './gr-change-list';
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index ace2fb5..db1f2f5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -377,8 +377,8 @@
    *
    * private but used in test
    */
-  reload() {
-    if (!this.viewState) return Promise.resolve();
+  async reload() {
+    if (!this.viewState) return;
 
     // See `firstTimeLoad` comment above.
     if (!this.firstTimeLoad) {
@@ -398,27 +398,23 @@
     // Otherwise sending a query for 'owner:self' will result in an error.
     const isLoggedInUserDashboard =
       !project && !!this.loggedInUser && user === 'self';
-    return dashboardPromise
-      .then(res => {
-        if (res && res.title) {
-          fireTitleChange(res.title);
-        }
-        return this.fetchDashboardChanges(res, isLoggedInUserDashboard);
-      })
-      .then(() => {
-        this.maybeShowDraftsBanner();
-        // Only report the metric for the default personal dashboard.
-        if (type === DashboardType.USER && isLoggedInUserDashboard) {
-          this.reporting.dashboardDisplayed();
-        }
-      })
-      .catch(err => {
-        fireTitleChange(title || this.computeTitle(user));
-        this.reporting.error('Dashboard reload', err);
-      })
-      .finally(() => {
-        this.loading = false;
-      });
+    try {
+      const res = await dashboardPromise;
+      if (res && res.title) {
+        fireTitleChange(res.title);
+      }
+      await this.fetchDashboardChanges(res, isLoggedInUserDashboard);
+      this.maybeShowDraftsBanner();
+      // Only report the metric for the default personal dashboard.
+      if (type === DashboardType.USER && isLoggedInUserDashboard) {
+        this.reporting.dashboardDisplayed();
+      }
+    } catch (err) {
+      fireTitleChange(title || this.computeTitle(user));
+      this.reporting.error('Dashboard reload', err as Error);
+    } finally {
+      this.loading = false;
+    }
   }
 
   /**
@@ -427,14 +423,11 @@
    *
    * private but used in test
    */
-  fetchDashboardChanges(
+  async fetchDashboardChanges(
     res: UserDashboard | undefined,
     isLoggedInUserDashboard: boolean
-  ): Promise<void> {
-    if (!res) {
-      return Promise.resolve();
-    }
-
+  ) {
+    if (!res) return;
     let queries: string[];
 
     if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
@@ -454,36 +447,34 @@
       }
     }
 
-    return this.restApiService
-      .getChangesForMultipleQueries(undefined, queries)
-      .then(changes => {
-        if (!changes) {
-          throw new Error('getChanges returns undefined');
-        }
-        if (isLoggedInUserDashboard) {
-          // Last query ('owner:self limit:1') is only for evaluation if
-          // the user is "New" ie. haven't created any changes yet.
-          const lastResultSet = changes.pop();
-          this.showNewUserHelp = lastResultSet!.length === 0;
-        }
-        this.results = changes
-          .map((results, i) => {
-            return {
-              name: res.sections[i].name,
-              countLabel: this.computeSectionCountLabel(results),
-              query: res.sections[i].query,
-              results: this.maybeSortResults(res.sections[i].name, results),
-              emptyStateSlotName: slotNameBySectionName.get(
-                res.sections[i].name
-              ),
-            };
-          })
-          .filter(
-            (section, i) =>
-              i < res.sections.length &&
-              (!res.sections[i].hideIfEmpty || section.results.length)
-          );
-      });
+    const changes = await this.restApiService.getChangesForDashboard(
+      undefined,
+      queries
+    );
+    if (!changes) {
+      throw new Error('getChanges returns undefined');
+    }
+    if (isLoggedInUserDashboard) {
+      // Last query ('owner:self limit:1') is only for evaluation if
+      // the user is "New" ie. haven't created any changes yet.
+      const lastResultSet = changes.pop();
+      this.showNewUserHelp = lastResultSet!.length === 0;
+    }
+    this.results = changes
+      .map((results, i) => {
+        return {
+          name: res.sections[i].name,
+          countLabel: this.computeSectionCountLabel(results),
+          query: res.sections[i].query,
+          results: this.maybeSortResults(res.sections[i].name, results),
+          emptyStateSlotName: slotNameBySectionName.get(res.sections[i].name),
+        };
+      })
+      .filter(
+        (section, i) =>
+          i < res.sections.length &&
+          (!res.sections[i].hideIfEmpty || section.results.length)
+      );
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index 58a66a6..9b09c84 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-dashboard-view';
 import {GrDashboardView} from './gr-dashboard-view';
@@ -42,11 +43,11 @@
   let element: GrDashboardView;
 
   let getChangesStub: SinonStubbedMember<
-    RestApiService['getChangesForMultipleQueries']
+    RestApiService['getChangesForDashboard']
   >;
 
   setup(async () => {
-    getChangesStub = stubRestApi('getChangesForMultipleQueries');
+    getChangesStub = stubRestApi('getChangesForDashboard');
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getAccountDetails').returns(
       Promise.resolve({
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 23c5746..836d86a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -42,6 +42,7 @@
   ChangeActionDialog,
   ChangeInfo,
   CherryPickInput,
+  CommentThread,
   CommitId,
   InheritedBooleanInfo,
   isDetailedLabelInfo,
@@ -116,6 +117,8 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {configModelToken} from '../../../models/config/config-model';
 import {readJSONResponsePayload} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {when} from 'lit/directives/when.js';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -366,6 +369,8 @@
 
   @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog;
 
+  @query('#confirmPublishEditDialog') confirmPublishEditDialog?: GrDialog;
+
   @query('#moreActions') moreActions?: GrDropdown;
 
   @query('#secondaryActions') secondaryActions?: HTMLElement;
@@ -480,6 +485,8 @@
 
   @state() pluginsLoaded = false;
 
+  @state() threadsWithUnappliedSuggestions?: CommentThread[];
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -496,6 +503,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -568,6 +577,11 @@
       () => this.getConfigModel().repoConfig$,
       config => (this.privateByDefault = config?.private_by_default)
     );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threadsWithUnappliedSuggestions$,
+      x => (this.threadsWithUnappliedSuggestions = x)
+    );
   }
 
   override connectedCallback() {
@@ -620,6 +634,15 @@
         .hidden {
           display: none;
         }
+        .info {
+          background-color: var(--info-background);
+          padding: var(--spacing-l) var(--spacing-xl);
+          margin-bottom: var(--spacing-l);
+        }
+        .info gr-icon {
+          color: var(--selected-foreground);
+          margin-right: var(--spacing-xl);
+        }
         @media screen and (max-width: 50em) {
           #mainContent {
             flex-wrap: wrap;
@@ -786,6 +809,27 @@
             Do you really want to delete the edit?
           </div>
         </gr-dialog>
+        <gr-dialog
+          id="confirmPublishEditDialog"
+          class="confirmDialog"
+          confirm-label="Publish"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handlePublishEditConfirm}
+        >
+          <div class="header" slot="header">Publish Change Edit</div>
+          <div class="main" slot="main">
+            ${when(
+              this.numberOfThreadsWithUnappliedSuggestions() > 0,
+              () => html`<p class="info">
+                <gr-icon id="icon" icon="info" small></gr-icon>
+                Heads Up! ${this.numberOfThreadsWithUnappliedSuggestions()}
+                comments have suggestions you can apply before publishing
+              </p>`
+            )}
+            Do you really want to publish the edit?
+          </div>
+        </gr-dialog>
       </dialog>
     `;
   }
@@ -1691,6 +1735,23 @@
     );
   }
 
+  private handlePublishEditConfirm() {
+    this.hideAllDialogs();
+
+    if (!this.actions.publishEdit) return;
+
+    // We need to make sure that all cached version of a change
+    // edit are deleted.
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
+
+    this.fireAction(
+      '/edit:publish',
+      assertUIActionInfo(this.actions.publishEdit),
+      false,
+      {notify: NotifyType.NONE}
+    );
+  }
+
   // private but used in test
   handleSubmitConfirm() {
     if (!this.canSubmitChange()) {
@@ -2044,18 +2105,16 @@
   }
 
   private handlePublishEditTap() {
-    if (!this.actions.publishEdit) return;
-
-    // We need to make sure that all cached version of a change
-    // edit are deleted.
-    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
-
-    this.fireAction(
-      '/edit:publish',
-      assertUIActionInfo(this.actions.publishEdit),
-      false,
-      {notify: NotifyType.NONE}
-    );
+    if (this.numberOfThreadsWithUnappliedSuggestions() > 0) {
+      assertIsDefined(
+        this.confirmPublishEditDialog,
+        'confirmPublishEditDialog'
+      );
+      this.showActionDialog(this.confirmPublishEditDialog);
+    } else {
+      // Skip confirmation dialog and publish immediately.
+      this.handlePublishEditConfirm();
+    }
   }
 
   private handleRebaseEditTap() {
@@ -2212,6 +2271,11 @@
   private handleStopEditTap() {
     fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
   }
+
+  private numberOfThreadsWithUnappliedSuggestions() {
+    if (!this.threadsWithUnappliedSuggestions) return 0;
+    return this.threadsWithUnappliedSuggestions.length;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 8275c0b..e122fd3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-change-actions';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
@@ -278,6 +279,18 @@
                 Do you really want to delete the edit?
               </div>
             </gr-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Publish"
+              confirm-on-enter=""
+              id="confirmPublishEditDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Publish Change Edit</div>
+              <div class="main" slot="main">
+                Do you really want to publish the edit?
+              </div>
+            </gr-dialog>
           </dialog>
         `
       );
@@ -2431,6 +2444,7 @@
         element.latestPatchNum = 12 as PatchSetNumber;
         element.change = {
           ...createChangeViewChange(),
+          current_revision_number: element.latestPatchNum,
           revisions: createRevisions(element.latestPatchNum as number),
           messages: createChangeMessages(1),
         };
@@ -2446,11 +2460,11 @@
       suite('happy path', () => {
         let executeChangeActionStub: sinon.SinonStub;
         setup(() => {
-          stubRestApi('getChangeDetail').returns(
+          stubRestApi('getChange').returns(
             Promise.resolve({
               ...createChangeViewChange(),
               // element has latest info
-              revisions: createRevisions(element.latestPatchNum as number),
+              current_revision_number: element.latestPatchNum!,
               messages: createChangeMessages(1),
             })
           );
@@ -2523,9 +2537,6 @@
               })
             );
             executeChangeActionStub.resolves(response);
-            stubRestApi('getChange').returns(
-              Promise.resolve(createChangeViewChange())
-            );
             await element.send(
               HttpMethod.POST,
               {message: 'Revert'},
@@ -2613,13 +2624,12 @@
 
       suite('failure modes', () => {
         test('non-latest', () => {
-          stubRestApi('getChangeDetail').returns(
+          stubRestApi('getChange').returns(
             Promise.resolve({
               ...createChangeViewChange(),
               // new patchset was uploaded
-              revisions: createRevisions(
-                (element.latestPatchNum as number) + 1
-              ),
+              current_revision_number: (element.latestPatchNum! +
+                1) as PatchSetNumber,
               messages: createChangeMessages(1),
             })
           );
@@ -2643,11 +2653,11 @@
         });
 
         test('send fails', () => {
-          stubRestApi('getChangeDetail').returns(
+          stubRestApi('getChange').returns(
             Promise.resolve({
               ...createChangeViewChange(),
               // element has latest info
-              revisions: createRevisions(element.latestPatchNum as number),
+              current_revision_number: element.latestPatchNum!,
               messages: createChangeMessages(1),
             })
           );
@@ -2680,14 +2690,29 @@
         });
 
         test('revert single change change not reachable', async () => {
-          stubRestApi('getChangeDetail').returns(
-            Promise.resolve({
-              ...createChangeViewChange(),
-              // element has latest info
-              revisions: createRevisions(element.latestPatchNum as number),
-              messages: createChangeMessages(1),
-            })
-          );
+          let getChangeCall = 0;
+          let errorFired = false;
+          stubRestApi('getChange').callsFake((_, errFn) => {
+            ++getChangeCall;
+            if (getChangeCall === 1) {
+              return Promise.resolve({
+                ...createChangeViewChange(),
+                // element has latest info
+                current_revision_number: element.latestPatchNum!,
+                messages: createChangeMessages(1),
+              });
+            } else {
+              // Mimics the behaviour of gr-rest-api-impl: If errFn is passed
+              // call it and return undefined, otherwise call fireNetworkError
+              // or fireServerError.
+              if (errFn) {
+                errFn.call(undefined);
+              } else {
+                errorFired = true;
+              }
+              return Promise.resolve(undefined);
+            }
+          });
           const setUrlStub = sinon.stub(
             testResolver(navigationToken),
             'setUrl'
@@ -2703,18 +2728,6 @@
               _number: 12345,
             })
           );
-          let errorFired = false;
-          // Mimics the behaviour of gr-rest-api-impl: If errFn is passed call
-          // it and return undefined, otherwise call fireNetworkError or
-          // fireServerError.
-          stubRestApi('getChange').callsFake((_, errFn) => {
-            if (errFn) {
-              errFn.call(undefined);
-            } else {
-              errorFired = true;
-            }
-            return Promise.resolve(undefined);
-          });
 
           await element.send(
             HttpMethod.POST,
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index f5e403d..a94d8e6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -345,7 +345,6 @@
       ${this.renderMergedAs()} ${this.renderShowRevertCreatedAs()}
       ${this.renderTopic()} ${this.renderCherryPickOf()}
       ${this.renderRevertOf()} ${this.renderStrategy()} ${this.renderHashTags()}
-      ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param
           name="labels"
@@ -359,6 +358,8 @@
           name="revision"
           .value=${this.revision}
         ></gr-endpoint-param>
+        <gr-endpoint-slot name="above-submit-requirements"></gr-endpoint-slot>
+        ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
       </gr-endpoint-decorator>
     </div>`;
   }
@@ -1265,6 +1266,7 @@
   private getHashtagSuggestions(
     input: string
   ): Promise<AutocompleteSuggestion[]> {
+    const inputReg = input.startsWith('^') ? new RegExp(input) : null;
     return this.restApiService
       .getChangesWithSimilarHashtag(input, throwingErrorCallback)
       .then(response =>
@@ -1272,6 +1274,9 @@
           .flatMap(change => change.hashtags ?? [])
           .filter(isDefined)
           .filter(unique)
+          .filter(hashtag =>
+            inputReg ? inputReg.test(hashtag) : hashtag.includes(input)
+          )
           .map(hashtag => {
             return {name: hashtag, value: hashtag};
           })
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 87875b2..e96caa4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-change-metadata';
 
@@ -184,13 +185,14 @@
         <span class="title"> Hashtags </span>
         <span class="value"> </span>
       </section>
-      <div class="separatedSection">
-      <gr-submit-requirements></gr-submit-requirements>
-      </div>
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param name="labels"> </gr-endpoint-param>
         <gr-endpoint-param name="change"> </gr-endpoint-param>
         <gr-endpoint-param name="revision"> </gr-endpoint-param>
+        <gr-endpoint-slot name="above-submit-requirements"></gr-endpoint-slot>
+        <div class="separatedSection">
+          <gr-submit-requirements></gr-submit-requirements>
+        </div>
       </gr-endpoint-decorator>
     </div>`
     );
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 6919982..f1ef362 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -145,7 +145,6 @@
   changeViewModelToken,
   ChangeViewState,
   createChangeUrl,
-  createEditUrl,
 } from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {userModelToken} from '../../../models/user/user-model';
@@ -153,6 +152,7 @@
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {assign} from '../../../utils/location-util';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -323,7 +323,6 @@
   @state()
   replyDisabled = true;
 
-  @state()
   private updateCheckTimerHandle?: number | null;
 
   @state() editMode = false;
@@ -492,6 +491,7 @@
     // TODO: Do we still need docOnly bindings?
     this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.SAVE_COMMENT, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
       this.getChangeModel().navigateToChangeResetReload()
     );
@@ -1288,8 +1288,7 @@
     const hideEditCommitMessage = this.computeHideEditCommitMessage(
       this.loggedIn,
       this.editingCommitMessage,
-      this.change,
-      this.editMode
+      this.change
     );
     return html` <div class="changeInfo">
       <div class="changeInfo-column changeMetadata">
@@ -1706,14 +1705,12 @@
   computeHideEditCommitMessage(
     loggedIn: boolean,
     editing: boolean,
-    change?: ParsedChangeInfo,
-    editMode?: boolean
+    change?: ParsedChangeInfo
   ) {
     if (
       !loggedIn ||
       editing ||
-      (change && change.status === ChangeStatus.MERGED) ||
-      editMode
+      (change && change.status === ChangeStatus.MERGED)
     ) {
       return true;
     }
@@ -1810,7 +1807,10 @@
     if (this.loggedIn) {
       this.openReplyDialog(FocusTarget.ANY);
     } else {
-      this.getNavigation().setUrl(this.loginUrl);
+      // We are not using `this.getNavigation().setUrl()`, because the login
+      // page is served directly from the backend and is not part of the web
+      // app.
+      assign(window.location, this.loginUrl);
     }
   }
 
@@ -2062,7 +2062,7 @@
           fire(this, 'hide-alert', {});
         });
     }
-    this.change = newChange;
+    this.getChangeModel().updateStateChange(newChange);
   }
 
   // Private but used in tests.
@@ -2397,13 +2397,10 @@
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
-        assertIsDefined(this.patchNum, 'patchset number');
         this.getNavigation().setUrl(
-          createEditUrl({
-            changeNum: this.change._number,
-            repo: this.change.project,
-            patchNum: this.patchNum,
+          this.getViewModel().editUrl({
             editView: {path},
+            patchNum: this.patchNum,
           })
         );
         break;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 4d6403a..ca3de4a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../../edit/gr-edit-constants';
 import '../gr-thread-list/gr-thread-list';
@@ -73,7 +74,10 @@
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeChildView} from '../../../models/views/change';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -308,6 +312,12 @@
 
   setup(async () => {
     setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+    sinon
+      .stub(testResolver(changeViewModelToken), 'editUrl')
+      .returns('fakeEditUrl');
+    sinon
+      .stub(testResolver(changeViewModelToken), 'diffUrl')
+      .returns('fakeDiffUrl');
 
     stubRestApi('getConfig').returns(
       Promise.resolve({
@@ -1175,12 +1185,7 @@
     assert.isTrue(
       element.computeHideEditCommitMessage(true, false, mergedChanged)
     );
-    assert.isTrue(
-      element.computeHideEditCommitMessage(true, false, change, true)
-    );
-    assert.isFalse(
-      element.computeHideEditCommitMessage(true, false, change, false)
-    );
+    assert.isFalse(element.computeHideEditCommitMessage(true, false, change));
   });
 
   test('handleCommitMessageSave trims trailing whitespace', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index 1285664..4f4963a 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -30,7 +30,6 @@
 
 @customElement('gr-commit-info')
 export class GrCommitInfo extends LitElement {
-  // TODO(TS): Maybe limit to StandaloneCommitInfo.
   @property({type: Object})
   commitInfo?: Partial<CommitInfo>;
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
index 3602ebc..e449332 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-confirm-abandon-dialog';
 import {GrConfirmAbandonDialog} from './gr-confirm-abandon-dialog';
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
index 891175f..61cc227 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
 import {queryAndAssert} from '../../../utils/common-util';
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
index 4655c71..d3e48ca 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-confirm-cherrypick-dialog';
 import {
@@ -19,6 +20,7 @@
   ChangeInfoId,
   ChangeStatus,
   CommitId,
+  EmailAddress,
   GitRef,
   HttpMethod,
   NumericChangeId,
@@ -67,11 +69,11 @@
 
 const emails = [
   {
-    email: 'primary@email.com',
+    email: 'primary@email.com' as EmailAddress,
     preferred: true,
   },
   {
-    email: 'secondary@email.com',
+    email: 'secondary@email.com' as EmailAddress,
     preferred: false,
   },
 ];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 038fcd5..ea58df9 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-confirm-rebase-dialog';
 import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
@@ -174,7 +175,7 @@
     test('hide rebaseWithCommitterEmail dialog when committer has single email', async () => {
       element.committerEmailDropdownItems = [
         {
-          email: 'test1@example.com',
+          email: 'test1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
@@ -186,12 +187,12 @@
     test('show rebaseWithCommitterEmail dialog when committer has more than one email', async () => {
       element.committerEmailDropdownItems = [
         {
-          email: 'test1@example.com',
+          email: 'test1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
         {
-          email: 'test2@example.com',
+          email: 'test2@example.com' as EmailAddress,
           pending_confirmation: true,
         },
       ];
@@ -230,12 +231,12 @@
       };
       element.committerEmailDropdownItems = [
         {
-          email: 'currentuser1@example.com',
+          email: 'currentuser1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
         {
-          email: 'currentuser2@example.com',
+          email: 'currentuser2@example.com' as EmailAddress,
           pending_confirmation: true,
         },
       ];
@@ -264,12 +265,12 @@
       };
       element.committerEmailDropdownItems = [
         {
-          email: 'uploader1@example.com',
+          email: 'uploader1@example.com' as EmailAddress,
           preferred: true,
           pending_confirmation: true,
         },
         {
-          email: 'uploader2@example.com',
+          email: 'uploader2@example.com' as EmailAddress,
           preferred: false,
           pending_confirmation: true,
         },
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 920ff00..ad19f85 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
 import {createParsedChange} from '../../../test/test-data-generators';
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
index 37dd9aa..f2c536b 100644
--- a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {fixture, html, assert} from '@open-wc/testing';
 import './gr-copy-links';
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index c730671..18da58d 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {
   createChange,
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
index e6005c2..8bf316f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-file-list-header';
 import {FilesExpandedState} from '../gr-file-list-constants';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index dc75a16..3ecbf74 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -80,9 +80,8 @@
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {
-  createDiffUrl,
-  createEditUrl,
   createChangeUrl,
+  changeViewModelToken,
 } from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -212,7 +211,7 @@
   diffViewMode?: DiffViewMode;
 
   @property({type: Boolean})
-  editMode?: boolean;
+  editMode = false;
 
   private _filesExpanded = FilesExpandedState.NONE;
 
@@ -313,6 +312,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   // private but used in test
   fileCursor = new GrCursorManager();
 
@@ -974,10 +975,7 @@
       <h3 class="assistive-tech-only">File list</h3>
       ${this.renderContainer()} ${this.renderChangeTotals(patchChange)}
       ${this.renderBinaryTotals(patchChange)} ${this.renderControlRow()}
-      <gr-diff-preferences-dialog
-        id="diffPreferencesDialog"
-        @reload-diff-preference=${this.handleReloadingDiffPreference}
-      >
+      <gr-diff-preferences-dialog id="diffPreferencesDialog">
       </gr-diff-preferences-dialog>
     `;
   }
@@ -1489,7 +1487,11 @@
         this.editMode,
         () => html`
           <gr-edit-file-controls
-            class=${this.computeClass('', file.__path)}
+            class=${this.computeClass(
+              '',
+              file.__path,
+              /* showForCommitMessage */ true
+            )}
             .filePath=${file.__path}
           ></gr-edit-file-controls>
         `
@@ -2166,15 +2168,13 @@
   // Private but used in tests.
   openCursorFile() {
     const diff = this.diffCursor?.getTargetDiffElement();
-    if (!this.change || !diff || !this.patchRange || !diff.path) {
-      throw new Error('change, diff and patchRange must be all set and valid');
+    if (!this.change || !diff || !this.patchNum || !diff.path) {
+      throw new Error('change, diff and pacthNum must be all set and valid');
     }
     this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+      this.getViewModel().diffUrl({
         diffView: {path: diff.path},
+        patchNum: this.patchNum,
       })
     );
   }
@@ -2187,15 +2187,13 @@
     if (!this.files[this.fileCursor.index]) {
       return;
     }
-    if (!this.change || !this.patchRange) {
+    if (!this.change || !this.patchNum) {
       throw new Error('change and patchRange must be set');
     }
     this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+      this.getViewModel().diffUrl({
         diffView: {path: this.files[this.fileCursor.index].__path},
+        patchNum: this.patchNum,
       })
     );
   }
@@ -2213,30 +2211,21 @@
     );
   }
 
+  /** Returns an edit or diff URL depending on `editMode`. */
   // Private but used in tests
-  computeDiffURL(path?: string) {
-    if (
-      this.change === undefined ||
-      this.patchRange?.patchNum === undefined ||
-      path === undefined ||
-      this.editMode === undefined
-    ) {
-      return;
-    }
+  computeDiffURL(path?: string): string | undefined {
+    if (path === undefined) return;
+    if (this.patchNum === undefined) return;
+
     if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return createEditUrl({
-        changeNum: this.change._number,
-        repo: this.change.project,
-        patchNum: this.patchRange.patchNum,
+      return this.getViewModel().editUrl({
+        patchNum: this.patchNum,
         editView: {path},
       });
     }
-    return createDiffUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: this.patchRange.basePatchNum,
+    return this.getViewModel().diffUrl({
       diffView: {path},
+      patchNum: this.patchNum,
     });
   }
 
@@ -2276,11 +2265,17 @@
     return delta > 0 ? 'added' : 'removed';
   }
 
-  private computeClass(baseClass?: string, path?: string) {
-    const classes = [];
-    if (baseClass) classes.push(baseClass);
-    if (isMagicPath(path)) classes.push('invisible');
-    return classes.join(' ');
+  // Private but used in tests.
+  computeClass(baseClass = '', path?: string, showForCommitMessage = false) {
+    const classes = [baseClass];
+    if (
+      !(showForCommitMessage && path === SpecialFilePath.COMMIT_MESSAGE) &&
+      isMagicPath(path)
+    ) {
+      classes.push('invisible');
+    }
+
+    return classes.join(' ').trim();
   }
 
   private computePathClass(path: string | undefined) {
@@ -2648,10 +2643,6 @@
     return this.filesExpanded === FilesExpandedState.NONE;
   }
 
-  private handleReloadingDiffPreference() {
-    this.getUserModel().getDiffPreferences();
-  }
-
   private getOldPath(file: NormalizedFileInfo) {
     // The gr-endpoint-decorator is waiting until all gr-endpoint-param
     // values are updated.
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 1cfbb83..a375e1d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import './gr-file-list';
@@ -39,6 +40,7 @@
 import {
   createDefaultDiffPrefs,
   DiffViewMode,
+  SpecialFilePath,
 } from '../../../constants/constants';
 import {
   assertIsDefined,
@@ -59,6 +61,11 @@
 import {FileMode} from '../../../utils/file-util';
 import {SinonStubbedMember} from 'sinon';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -78,6 +85,15 @@
   let element: GrFileList;
   let saveStub: sinon.SinonStub;
 
+  setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 42 as NumericChangeId,
+      repo: 'gerrit' as RepoName,
+    });
+  });
+
   suite('basic tests', async () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -178,7 +194,7 @@
             <gr-file-status></gr-file-status>
           </div>
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/2/path/file0">
               <span class="fullFileName" title="path/file0">
                 <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
@@ -298,7 +314,7 @@
         fileRows[0].querySelector('.path'),
         /* HTML */ `
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/2/path/file0">
               <span class="fullFileName" title="path/file0">
                 <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
@@ -316,7 +332,7 @@
         fileRows[1].querySelector('.path'),
         /* HTML */ `
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/2/path/file1">
               <span class="fullFileName" title="path/file1">
                 <span class="matchingFilePath"> path/ </span>
                 <span class="fileName"> file1 </span>
@@ -946,10 +962,10 @@
         ];
         element.changeNum = 42 as NumericChangeId;
         element.basePatchNum = PARENT;
-        element.patchNum = 2 as RevisionPatchSetNum;
+        element.patchNum = 1 as RevisionPatchSetNum;
         element.change = {
           _number: 42 as NumericChangeId,
-          project: 'test-project',
+          project: 'gerrit',
         } as ParsedChangeInfo;
         element.fileCursor.setCursorAtIndex(0);
         await element.updateComplete;
@@ -1008,7 +1024,7 @@
         assert.equal(setUrlStub.callCount, 1);
         assert.equal(
           setUrlStub.lastCall.firstArg,
-          '/c/test-project/+/42/2/file_added_in_rev2.txt'
+          '/c/gerrit/+/42/1/file_added_in_rev2.txt'
         );
 
         pressKey(element, 'k');
@@ -1704,35 +1720,25 @@
 
   suite('diff url file list', () => {
     test('diff url', () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 1 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-      };
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
       const path = 'index.php';
+      element.patchNum = 1 as RevisionPatchSetNum;
       element.editMode = false;
-      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/42/1/index.php');
     });
 
     test('diff url commit msg', () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 1 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-      };
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      element.editMode = false;
       const path = '/COMMIT_MSG';
-      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
+      element.editMode = false;
+      assert.equal(
+        element.computeDiffURL(path),
+        '/c/gerrit/+/42/1//COMMIT_MSG'
+      );
     });
 
     test('edit url', () => {
       element.change = {
         ...createParsedChange(),
-        _number: 1 as NumericChangeId,
+        _number: 42 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
       element.basePatchNum = PARENT;
@@ -1741,14 +1747,14 @@
       const path = 'index.php';
       assert.equal(
         element.computeDiffURL(path),
-        '/c/gerrit/+/1/1/index.php,edit'
+        '/c/gerrit/+/42/1/index.php,edit'
       );
     });
 
     test('edit url commit msg', () => {
       element.change = {
         ...createParsedChange(),
-        _number: 1 as NumericChangeId,
+        _number: 42 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
       element.basePatchNum = PARENT;
@@ -1757,7 +1763,7 @@
       const path = '/COMMIT_MSG';
       assert.equal(
         element.computeDiffURL(path),
-        '/c/gerrit/+/1/1//COMMIT_MSG,edit'
+        '/c/gerrit/+/42/1//COMMIT_MSG,edit'
       );
     });
   });
@@ -2259,11 +2265,41 @@
       element.editMode = true;
       await element.updateComplete;
 
-      // Commit message should not have edit controls.
+      // Commit message can have edit controls.
       const editControls = Array.from(
         queryAll(element, '.row:not(.header-row)')
       ).map(row => row.querySelector('gr-edit-file-controls'));
-      assert.isTrue(editControls[0]!.classList.contains('invisible'));
+      assert.isFalse(editControls[0]!.classList.contains('invisible'));
+    });
+  });
+
+  suite('computeClass', () => {
+    test('works', () => {
+      assert.equal(
+        element.computeClass(
+          '',
+          SpecialFilePath.MERGE_LIST,
+          /* showForCommitMessage */ true
+        ),
+        'invisible'
+      );
+      assert.equal(
+        element.computeClass(
+          '',
+          SpecialFilePath.COMMIT_MESSAGE,
+          /* showForCommitMessage */ true
+        ),
+        ''
+      );
+      assert.equal(
+        element.computeClass(
+          '',
+          SpecialFilePath.COMMIT_MESSAGE,
+          /* showForCommitMessage */ false
+        ),
+        'invisible'
+      );
+      assert.equal(element.computeClass('', 'file.java'), '');
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
index 8752a6c..7129247 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-label-score-row';
 import {GrLabelScoreRow} from './gr-label-score-row';
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 65d36ec..77610dc6 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -50,6 +50,9 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {fire} from '../../../utils/event-util';
 import {ChangeMessageDeletedEventDetail} from '../../../types/events';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -97,9 +100,6 @@
     return this.message?.author || this.message?.updated_by;
   }
 
-  @property({type: Object})
-  config?: ServerInfo;
-
   @property({type: Boolean})
   hideAutomated = false;
 
@@ -110,11 +110,14 @@
   @property({type: Object})
   labelExtremes?: LabelExtreme;
 
-  @property({type: Boolean})
+  @state()
   loggedIn = false;
 
   @state()
-  private isAdmin = false;
+  config?: ServerInfo;
+
+  @state()
+  isAdmin = false;
 
   @state()
   private isDeletingChangeMsg = false;
@@ -123,22 +126,28 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
   constructor() {
     super();
     this.addEventListener('click', e => this.handleClick(e));
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then(config => {
-      this.config = config;
-    });
-    this.restApiService.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.restApiService.getIsAdmin().then(isAdmin => {
-      this.isAdmin = !!isAdmin;
-    });
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      x => (this.config = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().isAdmin$,
+      x => (this.isAdmin = x)
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 1ed2729..afd8ac2 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-message';
 import {
@@ -49,8 +50,8 @@
 
   suite('when admin and logged in', () => {
     setup(async () => {
-      stubRestApi('getIsAdmin').returns(Promise.resolve(true));
       element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
+      element.isAdmin = true;
     });
 
     test('can see delete button', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index df04f68..d4c7b63 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-messages-list';
 import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
@@ -33,7 +34,12 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
 import {testResolver} from '../../../test/common-test-setup';
-import {commentsModelToken} from '../../../models/comments/comments-model';
+import {TEST_PROJECT_NAME} from '../../../test/test-data-generators';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 const author = {
   _account_id: 42 as AccountId,
@@ -138,9 +144,12 @@
       element = await fixture<GrMessagesList>(
         html`<gr-messages-list></gr-messages-list>`
       );
-      await testResolver(commentsModelToken).reloadComments(
-        0 as NumericChangeId
-      );
+      testResolver(changeViewModelToken).setState({
+        view: GerritView.CHANGE,
+        childView: ChangeChildView.OVERVIEW,
+        changeNum: 123 as NumericChangeId,
+        repo: TEST_PROJECT_NAME,
+      });
       element.messages = messages;
       await element.updateComplete;
     });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 984391d..e2a1d63 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -277,7 +277,8 @@
 
     return html`<section id="relatedChanges">
       <gr-related-collapse
-        title="Relation chain"
+        .name=${'Relation chain'}
+        title="parent changes are ordered after child changes"
         class=${classMap({first: isFirst})}
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
@@ -342,7 +343,8 @@
     );
     return html`<section id="submittedTogether">
       <gr-related-collapse
-        title="Submitted together"
+        .name=${'Submitted together'}
+        title="parent changes are ordered after child changes"
         class=${classMap({first: isFirst})}
         .length=${submittedTogetherChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
@@ -405,7 +407,7 @@
     );
     return html`<section id="sameTopic">
       <gr-related-collapse
-        title="Same topic"
+        .name=${'Same topic'}
         class=${classMap({first: isFirst})}
         .length=${this.sameTopicChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
@@ -445,7 +447,7 @@
     );
     return html`<section id="mergeConflicts">
       <gr-related-collapse
-        title="Merge conflicts"
+        .name=${'Merge conflicts'}
         class=${classMap({first: isFirst})}
         .length=${this.conflictingChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
@@ -490,7 +492,7 @@
     );
     return html`<section id="cherryPicks">
       <gr-related-collapse
-        title="Cherry picks"
+        .name=${'Cherry picks'}
         class=${classMap({first: isFirst})}
         .length=${this.cherryPickChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 2e33333..e8bf442 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -200,7 +200,10 @@
             <gr-endpoint-param name="change"> </gr-endpoint-param>
             <gr-endpoint-slot name="top"> </gr-endpoint-slot>
             <section id="relatedChanges">
-              <gr-related-collapse class="first" title="Relation chain">
+              <gr-related-collapse
+                class="first"
+                title="parent changes are ordered after child changes"
+              >
                 <div class="relatedChangeLine show-when-collapsed">
                   <span class="marker space"> </span>
                   <gr-related-change
@@ -213,7 +216,9 @@
               </gr-related-collapse>
             </section>
             <section id="submittedTogether">
-              <gr-related-collapse title="Submitted together">
+              <gr-related-collapse
+                title="parent changes are ordered after child changes"
+              >
                 <div class="relatedChangeLine selected show-when-collapsed">
                   <span
                     aria-label="Arrow marking current change"
@@ -236,7 +241,7 @@
               <div class="note" hidden="">(+ )</div>
             </section>
             <section id="cherryPicks">
-              <gr-related-collapse title="Cherry picks">
+              <gr-related-collapse>
                 <div class="relatedChangeLine show-when-collapsed">
                   <span class="marker space"> </span>
                   <gr-related-change show-change-status="">
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
index 30d2282..8c2f459 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
@@ -18,7 +18,7 @@
 @customElement('gr-related-collapse')
 export class GrRelatedCollapse extends LitElement {
   @property()
-  override title = '';
+  name = '';
 
   @property({type: Boolean})
   showAll = false;
@@ -64,7 +64,7 @@
   }
 
   override render() {
-    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
+    const title = html`<h3 class="title heading-3">${this.name}</h3>`;
 
     const collapsible = this.length > this.numChangesWhenCollapsed;
     this.collapsed = !this.showAll && collapsible;
@@ -88,7 +88,7 @@
     e.stopPropagation();
     this.showAll = !this.showAll;
     this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: this.title,
+      sectionName: this.name,
       toState: this.showAll ? 'Show all' : 'Show less',
     });
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
index 4f951f3..504c8b5 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-reply-dialog';
 import {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index d6b110e..e6717b2 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -88,6 +88,7 @@
   fireNoBubble,
   fireIronAnnounce,
   fireServerError,
+  fireReload,
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
 import {DelayedTask} from '../../../utils/async-util';
@@ -122,7 +123,7 @@
 import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {userModelToken} from '../../../models/user/user-model';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {ironAnnouncerRequestAvailability} from '../../polymer-util';
@@ -787,8 +788,11 @@
               name="change"
               .value=${this.change}
             ></gr-endpoint-param>
-            ${this.renderAttentionSummarySection()}
-            ${this.renderAttentionDetailsSection()}
+            ${when(
+              this.attentionExpanded,
+              () => this.renderAttentionDetailsSection(),
+              () => this.renderAttentionSummarySection()
+            )}
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderActionsSection()}
           </gr-endpoint-decorator>
@@ -989,7 +993,6 @@
   }
 
   private renderAttentionSummarySection() {
-    if (this.attentionExpanded) return;
     return html`
       <section class="attention">
         <div class="attentionSummary">
@@ -1056,7 +1059,6 @@
   }
 
   private renderAttentionDetailsSection() {
-    if (!this.attentionExpanded) return;
     return html`
       <section class="attention-detail">
         <div class="attentionDetailsTitle">
@@ -1458,26 +1460,45 @@
     this.getNavigation().blockNavigation('sending review');
     return this.saveReview(reviewInput, errFn)
       .then(result => {
+        // change-info is not set only if request resulted in error.
+        if (!result?.change_info) {
+          return;
+        }
+
+        // saveReview response don't contain revision information, if the
+        // newer patchset was uploaded in the meantime, we should reload.
+        const reloadRequired =
+          result.change_info.current_revision_number !==
+          this.change?.current_revision_number;
+        // Update the state right away to update comments, even if the full
+        // reload is scheduled right after.
+        const updatedChange = {
+          ...result.change_info,
+          revisions: this.change?.revisions,
+          current_revision: this.change?.current_revision,
+          current_revision_number: this.change?.current_revision_number,
+        };
         this.getChangeModel().updateStateChange(
-          GrReviewerUpdatesParser.parse(
-            result?.change_info as ChangeViewChangeInfo
-          )
+          GrReviewerUpdatesParser.parse(updatedChange as ChangeViewChangeInfo)
         );
+        if (reloadRequired) {
+          fireReload(this);
+        }
 
         this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
         fireNoBubble(this, 'send', {});
         fireIronAnnounce(this, 'Reply sent');
-        return;
+        this.getPluginLoader().jsApiService.handleReplySent();
       })
-      .then(result => result)
       .finally(() => {
         this.getNavigation().releaseNavigation('sending review');
         this.disabled = false;
         if (this.patchsetLevelGrComment) {
           this.patchsetLevelGrComment.disableAutoSaving = false;
         }
-        // By this point in time the change has loaded, we're only waiting for the comments.
+        // The request finished and reloads if necessary are asynchronously
+        // scheduled.
         this.reporting.timeEnd(Timing.SEND_REPLY);
       });
   }
@@ -1666,9 +1687,10 @@
 
     if (this.change.status === ChangeStatus.NEW) {
       // Add everyone that the user is replying to in a comment thread.
-      this.computeCommentAccounts(draftCommentThreads).forEach(id =>
-        newAttention.add(id)
-      );
+      this.computeCommentAccountsForAttention(
+        draftCommentThreads,
+        isUploader
+      ).forEach(id => newAttention.add(id));
       // Remove the current user.
       newAttention.delete(this.account._account_id);
       // Add all new reviewers, but not the current reviewer, if they are also
@@ -1721,8 +1743,10 @@
       ),
     ]);
     // Possibly expand if need be, never collapse as this is jarring to the user.
+    // For long account lists (10 or more), avoid automatic expansion.
     this.attentionExpanded =
-      this.attentionExpanded || this.computeShowAttentionTip(1);
+      this.attentionExpanded ||
+      (allAccountIds.length < 10 && this.computeShowAttentionTip(1));
   }
 
   computeShowAttentionTip(minimum: number) {
@@ -1733,18 +1757,48 @@
     return this.isOwner && addedIds.length >= minimum;
   }
 
-  computeCommentAccounts(threads: CommentThread[]) {
+  /**
+   * Pick previous commenters for addition to attention set.
+   *
+   * For every thread:
+   *   - If owner replied and thread is unresolved: add all commenters.
+   *   - If owner replied and thread is resolved: add commenters who need to vote.
+   *   - If reviewer replied and thread is resolved: add commenters who need to vote.
+   *   - If reviewer replied and thread is unresolved: only add owner
+   *     (owner added outside this function).
+   */
+  computeCommentAccountsForAttention(
+    threads: CommentThread[],
+    isUploader: boolean
+  ) {
     const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW];
     const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
     threads.forEach(thread => {
       const unresolved = isUnresolved(thread);
+      let ignoreVoteCheck = false;
+      if (unresolved) {
+        if (this.isOwner || isUploader) {
+          // Owner replied but didn't resolve, we assume clarification was asked
+          // add everyone on the thread to attention set.
+          ignoreVoteCheck = true;
+        } else {
+          // Reviewer replied owner is still the one to act. No need to add
+          // commenters.
+          return;
+        }
+      }
+      // If thread is resolved, we only bring back the commenters who have not
+      // yet left max Code-Review vote.
       thread.comments.forEach(comment => {
         if (comment.author) {
           // A comment author must have an account_id.
           const authorId = comment.author._account_id!;
-          const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
-          if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
+          const needsToVote =
+            !maxCrVoteAccountIds.includes(authorId) && // Didn't give max-vote
+            this.uploader?._account_id !== authorId && // Not uploader
+            this.change?.owner._account_id !== authorId; // Not owner
+          if (ignoreVoteCheck || needsToVote) accountIds.add(authorId);
         }
       });
     });
@@ -1908,7 +1962,7 @@
       this.latestPatchNum,
       review,
       errFn,
-      true
+      /* fetchDetail=*/ true
     );
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 500aa63..eea645e 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-reply-dialog';
 import {
@@ -15,6 +16,7 @@
   queryAndAssert,
   stubReporting,
   stubRestApi,
+  waitEventLoop,
   waitUntilVisible,
 } from '../../../test/test-utils';
 import {
@@ -30,6 +32,7 @@
   createComment,
   createCommentThread,
   createDraft,
+  createLabelInfo,
   createRevision,
   createServiceUserWithId,
 } from '../../../test/test-data-generators';
@@ -37,6 +40,7 @@
 import {
   AccountId,
   AccountInfo,
+  ChangeInfo,
   CommentThread,
   CommitId,
   DetailedLabelInfo,
@@ -71,6 +75,8 @@
 import {isOwner} from '../../../utils/change-util';
 import {createNewPatchsetLevel} from '../../../utils/comment-util';
 import {Timing} from '../../../constants/reporting';
+import {ParsedChangeInfo} from '../../../types/types';
+import {changeModelToken} from '../../../models/change/change-model';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -97,6 +103,8 @@
   let changeNum: NumericChangeId;
   let latestPatchNum: PatchSetNumber;
   let commentsModel: CommentsModel;
+  let change: ParsedChangeInfo;
+  let changeNoRevisions: ChangeInfo;
 
   let lastId = 1;
   const makeAccount = function () {
@@ -112,15 +120,13 @@
   setup(async () => {
     changeNum = 42 as NumericChangeId;
     latestPatchNum = 1 as PatchSetNumber;
+    const owner: AccountInfo = {
+      _account_id: 999 as AccountId,
+      display_name: 'Kermit',
+      email: 'abcd' as EmailAddress,
+    };
 
-    stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
-    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
-
-    element = await fixture<GrReplyDialog>(html`
-      <gr-reply-dialog></gr-reply-dialog>
-    `);
-
-    element.change = {
+    changeNoRevisions = {
       ...createChange(),
       _number: changeNum,
       owner: {
@@ -148,7 +154,23 @@
           default_value: 0,
         },
       },
+
+      current_revision_number: 1 as PatchSetNumber,
     };
+    change = {
+      ...changeNoRevisions,
+      revisions: {'commit-id': {...createRevision(), uploader: owner}},
+      current_revision: 'commit-id' as CommitId,
+    };
+
+    stubRestApi('getChange').returns(Promise.resolve(change as ChangeInfo));
+    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
+
+    element = await fixture<GrReplyDialog>(html`
+      <gr-reply-dialog></gr-reply-dialog>
+    `);
+
+    element.change = change;
     element.latestPatchNum = latestPatchNum;
     element.permittedLabels = {
       'Code-Review': ['-1', ' 0', '+1'],
@@ -186,6 +208,9 @@
     });
     stubSaveReview((review: ReviewInput) => {
       resolver(review);
+      return {
+        change_info: changeNoRevisions,
+      };
     });
     return promise;
   }
@@ -1137,7 +1162,72 @@
     );
   });
 
-  test('computeCommentAccounts', () => {
+  test('computeCommentAccountsForAttention owner comments', () => {
+    element.change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          all: [
+            {_account_id: 1 as AccountId, value: 0},
+            {_account_id: 2 as AccountId, value: 1},
+            {_account_id: 3 as AccountId, value: 2},
+          ],
+          values: {
+            '-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',
+          },
+        },
+      },
+    };
+    element.isOwner = true;
+    const threads = [
+      {
+        ...createCommentThread([
+          {
+            ...createComment(),
+            id: '1' as UrlEncodedCommentId,
+            author: {_account_id: 1 as AccountId},
+            unresolved: false,
+          },
+          {
+            ...createComment(),
+            id: '2' as UrlEncodedCommentId,
+            in_reply_to: '1' as UrlEncodedCommentId,
+            author: {_account_id: 2 as AccountId},
+            unresolved: true,
+          },
+        ]),
+      },
+      {
+        ...createCommentThread([
+          {
+            ...createComment(),
+            id: '3' as UrlEncodedCommentId,
+            author: {_account_id: 3 as AccountId},
+            unresolved: false,
+          },
+          {
+            ...createComment(),
+            id: '4' as UrlEncodedCommentId,
+            in_reply_to: '3' as UrlEncodedCommentId,
+            author: {_account_id: 4 as AccountId},
+            unresolved: false,
+          },
+        ]),
+      },
+    ];
+    const actualAccounts = [
+      ...element.computeCommentAccountsForAttention(threads, false),
+    ];
+    // Account 3 is not included, because the comment is resolved *and* they
+    // have given the highest possible vote on the Code-Review label.
+    assert.sameMembers(actualAccounts, [1, 2, 4]);
+  });
+
+  test('computeCommentAccountsForAttention reviewer comments', () => {
     element.change = {
       ...createChange(),
       labels: {
@@ -1187,16 +1277,30 @@
             ...createComment(),
             id: '4' as UrlEncodedCommentId,
             in_reply_to: '3' as UrlEncodedCommentId,
+            author: element.change.owner,
+            unresolved: false,
+          },
+          {
+            ...createComment(),
+            id: '5' as UrlEncodedCommentId,
+            in_reply_to: '4' as UrlEncodedCommentId,
             author: {_account_id: 4 as AccountId},
             unresolved: false,
           },
         ]),
       },
     ];
-    const actualAccounts = [...element.computeCommentAccounts(threads)];
+    const actualAccounts = [
+      ...element.computeCommentAccountsForAttention(threads, false),
+    ];
+    // Accounts 1 and 2 are not included, because the thread is still unresolved
+    // and the new comment is from another reviewer.
     // Account 3 is not included, because the comment is resolved *and* they
     // have given the highest possible vote on the Code-Review label.
-    assert.sameMembers(actualAccounts, [1, 2, 4]);
+    // element.change.owner is similarly not included, because they don't need
+    // to vote. (In the overall logic owner is added as part of
+    // computeNewAttention)
+    assert.sameMembers(actualAccounts, [4]);
   });
 
   test('label picker', async () => {
@@ -1467,9 +1571,9 @@
         ...createChange(),
         status: ChangeStatus.NEW,
       };
+      const restApiPromise = interceptSaveReview();
       element.send(false, false);
-
-      await waitUntil(() => fireStub.called);
+      await restApiPromise;
 
       const events = fireStub.args.map(arg => arg[0].type || '');
       assert.isFalse(events.includes('show-alert'));
@@ -1489,9 +1593,9 @@
         status: ChangeStatus.NEW,
         work_in_progress: true,
       };
+      const restApiPromise = interceptSaveReview();
       element.send(false, true);
-
-      await waitUntil(() => fireStub.called);
+      await restApiPromise;
 
       const events = fireStub.args.map(arg => arg[0].type || '');
       assert.isFalse(events.includes('show-alert'));
@@ -1635,17 +1739,15 @@
 
   test('only send labels that have changed', async () => {
     await element.updateComplete;
+    const promise = mockPromise();
     stubSaveReview((review: ReviewInput) => {
       assert.deepEqual(review?.labels, {
         'Code-Review': 0,
         Verified: -1,
       });
-    });
-
-    const promise = mockPromise();
-    element.addEventListener('send', () => {
       promise.resolve();
     });
+
     // Without wrapping this test in await element.updateComplete, the below two
     // calls to tap() cause a race in some situations in shadow DOM. The send
     // button can be tapped before the others, causing the test to fail.
@@ -2616,6 +2718,92 @@
     );
   });
 
+  test('reload change if patchset updated', async () => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await element.updateComplete;
+    const changeModel = testResolver(changeModelToken);
+    const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange');
+    const responseChange = {
+      ...change,
+      labels: {Verified: createLabelInfo(-1)},
+      revisions: undefined,
+      current_revision: undefined,
+      current_revision_number: (change.current_revision_number +
+        1) as PatchSetNumber,
+    };
+    stubSaveReview(() => {
+      return {
+        change_info: responseChange as ChangeInfo,
+      };
+    });
+    const reloadPromise = mockPromise();
+    let reloadTriggered = false;
+    document.addEventListener('reload', () => {
+      reloadTriggered = true;
+      reloadPromise.resolve();
+    });
+
+    // Set a different label value
+    const el = queryAndAssert<GrLabelScoreRow>(
+      queryAndAssert(element, 'gr-label-scores'),
+      'gr-label-score-row[name="Verified"]'
+    );
+    el.setSelectedValue('-1');
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, '.send').click();
+    await element.updateComplete;
+
+    await reloadPromise;
+    assert.isTrue(reloadTriggered);
+
+    // All revision information is old, but all other information is new.
+    const expectedChange = {...change, labels: {Verified: createLabelInfo(-1)}};
+    assert.deepEqual(changeStateUpdateSpy.firstCall.args[0], expectedChange);
+  });
+
+  test('no reload if patchset is the same', async () => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await element.updateComplete;
+    const changeModel = testResolver(changeModelToken);
+    const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange');
+    const responseChange = {
+      ...change,
+      labels: {Verified: createLabelInfo(-1)},
+      revisions: undefined,
+      current_revision: undefined,
+      current_revision_number: change.current_revision_number,
+    };
+    stubSaveReview(() => {
+      return {
+        change_info: responseChange as ChangeInfo,
+      };
+    });
+    let reloadTriggered = false;
+    document.addEventListener('reload', () => {
+      reloadTriggered = true;
+    });
+
+    // Set a different label value
+    const el = queryAndAssert<GrLabelScoreRow>(
+      queryAndAssert(element, 'gr-label-scores'),
+      'gr-label-score-row[name="Verified"]'
+    );
+    el.setSelectedValue('-1');
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, '.send').click();
+    await element.updateComplete;
+
+    await waitEventLoop();
+    assert.isFalse(reloadTriggered);
+    // All revision information is old, but all other information is new.
+    const expectedChange = {...change, labels: {Verified: createLabelInfo(-1)}};
+    assert.deepEqual(changeStateUpdateSpy.firstCall.args[0], expectedChange);
+  });
+
   suite('mention users', () => {
     setup(async () => {
       element.account = createAccountWithId(1);
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index f050ab50..0fcb83c 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-reviewer-list';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
index 9cf5423..2dbbd26 100644
--- a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
@@ -3,6 +3,8 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-commit-info/gr-commit-info';
+import '../../shared/gr-button/gr-button';
 import {customElement, state} from 'lit/decorators.js';
 import {css, html, HTMLTemplateResult, LitElement} from 'lit';
 import {resolve} from '../../../models/dependency';
diff --git a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
index b9aa63d..d3b46ff 100644
--- a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
@@ -167,7 +167,7 @@
        The diff below may not be meaningful and may<br/>
        even be hiding relevant changes.
        <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
-       </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+       </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
     );
   });
 
@@ -183,7 +183,7 @@
             The diff below may not be meaningful and may<br/>
             even be hiding relevant changes.
             <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
-            </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+            </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
     );
   });
 
@@ -235,7 +235,7 @@
        The diff below may not be meaningful and may<br/>
        even be hiding relevant changes.
        <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
-       </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+       </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
     );
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 5c40050..86bf1b2 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -55,6 +55,8 @@
 export const __testOnly_SortDropdownState = SortDropdownState;
 
 /**
+ * TODO: Maybe reconcile this with `compareComments()` in comment-util.
+ *
  * Order as follows:
  * - Patchset level threads (descending based on patchset number)
  * - unresolved
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 1c494fa..abd8a23 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -3,9 +3,9 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {Action} from '../../api/checks';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {Action, NOT_USEFUL, USEFUL} from '../../api/checks';
 import {assertIsDefined} from '../../utils/common-util';
 import {resolve} from '../../models/dependency';
 import {checksModelToken} from '../../models/checks/checks-model';
@@ -21,6 +21,12 @@
   @property({type: String})
   context = 'unknown';
 
+  @property({type: String, reflect: true})
+  icon?: string;
+
+  @state()
+  clicked = false;
+
   private getChecksModel = resolve(this, checksModelToken);
 
   override connectedCallback() {
@@ -35,6 +41,10 @@
           display: inline-block;
           white-space: nowrap;
         }
+        :host([icon*='thumb']) gr-button {
+          display: block;
+          --gr-button-padding: 0 var(--spacing-s);
+        }
         gr-button {
           --gr-button-padding: var(--spacing-s) var(--spacing-m);
         }
@@ -48,6 +58,16 @@
     ];
   }
 
+  override willUpdate(_changedProperties: PropertyValues): void {
+    if (this.action.name === USEFUL) {
+      this.icon = 'thumb_up';
+    } else if (this.action.name === NOT_USEFUL) {
+      this.icon = 'thumb_down';
+    } else {
+      this.icon = undefined;
+    }
+  }
+
   override render() {
     return html`
       <gr-button
@@ -56,12 +76,19 @@
         class="action"
         @click=${(e: Event) => this.handleClick(e)}
       >
-        ${this.action.name}
+        ${this.renderName()}
       </gr-button>
       ${this.renderTooltip()}
     `;
   }
 
+  private renderName() {
+    if (!this.icon) return html`${this.action.name}`;
+    return html`
+      <gr-icon ?filled=${this.clicked} icon=${this.icon}></gr-icon>
+    `;
+  }
+
   private renderTooltip() {
     if (!this.action.tooltip) return;
     return html`
@@ -72,7 +99,13 @@
   }
 
   handleClick(e: Event) {
-    e.stopPropagation();
+    if (this.action.name === USEFUL || this.action.name === NOT_USEFUL) {
+      this.clicked = true;
+    } else {
+      // For useful clicks the parent wants to receive the click for changing
+      // the "Was this helpful?" label.
+      e.stopPropagation();
+    }
     this.getChecksModel().triggerAction(this.action, undefined, this.context);
   }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-fix-preview.ts b/polygerrit-ui/app/elements/checks/gr-checks-fix-preview.ts
new file mode 100644
index 0000000..1c8be76
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-fix-preview.ts
@@ -0,0 +1,192 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {GrSuggestionDiffPreview} from '../shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators.js';
+import {BasePatchSetNum, RepoName} from '../../types/common';
+import {resolve} from '../../models/dependency';
+import {
+  FixSuggestionInfo,
+  NumericChangeId,
+  PatchSetNumber,
+} from '../../api/rest-api';
+import {changeModelToken} from '../../models/change/change-model';
+import {subscribe} from '../lit/subscription-controller';
+import {fire} from '../../utils/event-util';
+import {OpenFixPreviewEventDetail} from '../../types/events';
+
+/**
+ * There is a certain overlap with `GrUserSuggestionsFix` which wraps
+ * `GrSuggestionDiffPreview` and has the header that we also need.
+ * But it is very targeted to be used for user suggestions and inside comments.
+ *
+ * So there is certainly an opportunity for cleanup and unification, but at the
+ * time of component creation it did not feel wortwhile investing into this
+ * effort. This is tracked in b/360288262.
+ */
+@customElement('gr-checks-fix-preview')
+export class GrChecksFixPreview extends LitElement {
+  @query('gr-suggestion-diff-preview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
+  @property({type: Object})
+  fixSuggestionInfo?: FixSuggestionInfo;
+
+  @property({type: Number})
+  patchSet?: PatchSetNumber;
+
+  @state()
+  repo?: RepoName;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  latestPatchNum?: PatchSetNumber;
+
+  @state() previewLoaded = false;
+
+  @state()
+  applyingFix = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repo = x)
+    );
+  }
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+        }
+        .header {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          border-bottom: none;
+          padding: var(--spacing-xs) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+        }
+        .header .title {
+          flex: 1;
+        }
+        .loading {
+          border: 1px solid var(--border-color);
+          padding: var(--spacing-xl);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.fixSuggestionInfo) return nothing;
+    return html`${this.renderHeader()}${this.renderDiff()}`;
+  }
+
+  private renderHeader() {
+    return html`
+      <div class="header">
+        <div class="title">
+          <span>Attached Fix</span>
+        </div>
+        <div>
+          <gr-button
+            class="showFix"
+            secondary
+            flatten
+            .disabled=${!this.previewLoaded}
+            @click=${this.showFix}
+          >
+            Show fix side-by-side
+          </gr-button>
+          <gr-button
+            class="applyFix"
+            primary
+            flatten
+            .loading=${this.applyingFix}
+            .disabled=${this.isApplyEditDisabled()}
+            @click=${this.applyFix}
+            .title=${this.computeApplyFixTooltip()}
+          >
+            Apply fix
+          </gr-button>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderDiff() {
+    return html`
+      <gr-suggestion-diff-preview
+        .fixSuggestionInfo=${this.fixSuggestionInfo}
+        .patchSet=${this.patchSet}
+        .codeText=${'Loading fix preview ...'}
+        @preview-loaded=${() => (this.previewLoaded = true)}
+      ></gr-suggestion-diff-preview>
+    `;
+  }
+
+  private showFix() {
+    if (!this.patchSet || !this.fixSuggestionInfo) return;
+    const eventDetail: OpenFixPreviewEventDetail = {
+      patchNum: this.patchSet,
+      fixSuggestions: [this.fixSuggestionInfo],
+      onCloseFixPreviewCallbacks: [],
+    };
+    fire(this, 'open-fix-preview', eventDetail);
+  }
+
+  /**
+   * Applies the fix and then navigates to the EDIT patchset.
+   */
+  private async applyFix() {
+    const changeNum = this.changeNum;
+    const basePatchNum = this.patchSet as BasePatchSetNum;
+    if (!changeNum || !basePatchNum || !this.fixSuggestionInfo) return;
+
+    this.applyingFix = true;
+    try {
+      await this.suggestionDiffPreview?.applyFix();
+    } finally {
+      this.applyingFix = false;
+    }
+  }
+
+  private isApplyEditDisabled() {
+    if (this.patchSet === undefined) return true;
+    return !this.previewLoaded;
+  }
+
+  private computeApplyFixTooltip() {
+    if (this.patchSet === undefined) return '';
+    if (!this.previewLoaded) return 'Fix is still loading ...';
+    return '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-fix-preview': GrChecksFixPreview;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-fix-preview_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-fix-preview_test.ts
new file mode 100644
index 0000000..7dbac27
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-fix-preview_test.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import * as sinon from 'sinon';
+import '../../test/common-test-setup';
+import './gr-checks-results';
+import './gr-checks-fix-preview';
+import {html} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+import {createCheckFix} from '../../test/test-data-generators';
+import {GrChecksFixPreview} from './gr-checks-fix-preview';
+import {rectifyFix} from '../../models/checks/checks-util';
+import {
+  MockPromise,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../test/test-utils';
+import {NumericChangeId, PatchSetNumber, RepoName} from '../../api/rest-api';
+import {FilePathToDiffInfoMap} from '../../types/common';
+import {GrSuggestionDiffPreview} from '../shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+
+suite('gr-checks-fix-preview test', () => {
+  let element: GrChecksFixPreview;
+  let promise: MockPromise<FilePathToDiffInfoMap | undefined>;
+
+  setup(async () => {
+    promise = mockPromise<FilePathToDiffInfoMap | undefined>();
+    stubRestApi('getFixPreview').returns(promise);
+
+    const fix = rectifyFix(createCheckFix(), 'test-checker');
+    element = await fixture<GrChecksFixPreview>(
+      html`<gr-checks-fix-preview></gr-checks-fix-preview>`
+    );
+    await element.updateComplete;
+
+    element.changeNum = 123 as NumericChangeId;
+    element.patchSet = 5 as PatchSetNumber;
+    element.latestPatchNum = 5 as PatchSetNumber;
+    element.repo = 'test-repo' as RepoName;
+    element.fixSuggestionInfo = fix;
+    await element.updateComplete;
+  });
+
+  test('renders loading', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="header">
+          <div class="title">
+            <span> Attached Fix </span>
+          </div>
+          <div>
+            <gr-button
+              class="showFix"
+              aria-disabled="true"
+              disabled=""
+              flatten=""
+              role="button"
+              secondary=""
+              tabindex="-1"
+            >
+              Show fix side-by-side
+            </gr-button>
+            <gr-button
+              class="applyFix"
+              aria-disabled="true"
+              disabled=""
+              flatten=""
+              primary=""
+              role="button"
+              tabindex="-1"
+              title="Fix is still loading ..."
+            >
+              Apply fix
+            </gr-button>
+          </div>
+        </div>
+        <gr-suggestion-diff-preview></gr-suggestion-diff-preview>
+      `
+    );
+  });
+
+  test('show-fix', async () => {
+    element.previewLoaded = true;
+    await element.updateComplete;
+    const stub = sinon.stub();
+    element.addEventListener('open-fix-preview', stub);
+
+    const button = queryAndAssert<HTMLElement>(element, 'gr-button.showFix');
+    assert.isFalse(button.hasAttribute('disabled'));
+    button.click();
+
+    assert.isTrue(stub.called);
+    assert.deepEqual(stub.lastCall.args[0].detail, {
+      patchNum: element.patchSet,
+      fixSuggestions: [element.fixSuggestionInfo],
+      onCloseFixPreviewCallbacks: [],
+    });
+  });
+
+  test('apply-fix', async () => {
+    element.previewLoaded = true;
+    await element.updateComplete;
+    const diffPreview = queryAndAssert<GrSuggestionDiffPreview>(
+      element,
+      'gr-suggestion-diff-preview'
+    );
+    const applyFixSpy = sinon.spy(diffPreview, 'applyFix');
+    stubRestApi('applyFixSuggestion').returns(
+      Promise.resolve({ok: true} as Response)
+    );
+
+    const button = queryAndAssert<HTMLElement>(element, 'gr-button.applyFix');
+    assert.isFalse(button.hasAttribute('disabled'));
+    button.click();
+
+    assert.isTrue(applyFixSpy.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index bc0e015..e81a190 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -24,8 +24,10 @@
   Category,
   Link,
   LinkIcon,
+  NOT_USEFUL,
   RunStatus,
   Tag,
+  USEFUL,
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {CheckRun, RunResult, runResult} from '../../models/checks/checks-model';
@@ -48,6 +50,7 @@
   secondaryLinks,
   tooltipForLink,
   computeIsExpandable,
+  rectifyFix,
 } from '../../models/checks/checks-util';
 import {assertIsDefined, assert, unique} from '../../utils/common-util';
 import {modifierPressed, whenVisible} from '../../utils/dom-util';
@@ -58,6 +61,7 @@
 import {
   DropdownLink,
   LabelNameToInfoMap,
+  PARENT,
   PatchSetNumber,
 } from '../../types/common';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
@@ -78,7 +82,8 @@
 import {when} from 'lit/directives/when.js';
 import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
 import './gr-checks-attempt';
-import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
+import './gr-checks-fix-preview';
+import {changeViewModelToken} from '../../models/views/change';
 import {formStyles} from '../../styles/form-styles';
 
 /**
@@ -241,6 +246,20 @@
           display: inline-block;
           margin-left: var(--spacing-s);
         }
+        /* actions-shown-on-collapsed are shown only when .actions is hidden
+          and vice versa. */
+        tr.container td .summary-cell .actions-shown-on-collapsed,
+        tr.container.collapsed:focus-within
+          td
+          .summary-cell
+          .actions-shown-on-collapsed,
+        tr.container.collapsed:hover
+          td
+          .summary-cell
+          .actions-shown-on-collapsed,
+        :host(.dropdown-open) tr td .summary-cell .actions-shown-on-collapsed {
+          display: none;
+        }
         tr.container.collapsed td .summary-cell .message {
           color: var(--deemphasized-text-color);
         }
@@ -248,6 +267,10 @@
         tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
+        tr.container.collapsed td .summary-cell .actions-shown-on-collapsed {
+          display: inline-block;
+          margin-left: var(--spacing-s);
+        }
         tr.detailsRow.collapsed {
           display: none;
         }
@@ -278,6 +301,7 @@
         td .summary-cell .tag.brown {
           background-color: var(--tag-brown);
         }
+        .actions-shown-on-collapsed gr-checks-action,
         .actions gr-checks-action,
         .actions gr-dropdown {
           /* Fitting a 28px button into 20px line-height. */
@@ -453,9 +477,11 @@
     this.toggleExpanded();
   }
 
-  private toggleExpanded() {
+  /** Toggles the expanded state, or if `setExpanded` is provided sets it to the desired state. */
+  toggleExpanded(setExpanded?: boolean) {
     if (!this.isExpandable) return;
-    this.isExpanded = !this.isExpanded;
+    this.isExpanded =
+      setExpanded === undefined ? !this.isExpanded : setExpanded;
     this.reporting.reportInteraction(Interaction.CHECKS_RESULT_ROW_TOGGLE, {
       expanded: this.isExpanded,
       checkName: this.result?.checkName,
@@ -467,7 +493,7 @@
     return html`
       <!-- The &nbsp; is for being able to shrink a tiny amount without
        the text itself getting shrunk with an ellipsis. -->
-      <div class="summary" @click=${this.toggleExpanded} title=${text}>
+      <div class="summary" @click=${this.toggleExpandedClick} title=${text}>
         ${text}&nbsp;
       </div>
     `;
@@ -495,7 +521,7 @@
     return html`
       <div class="label ${status}">
         <span>${label} ${valueStr}</span>
-        <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
+        <paper-tooltip offset="5" .fitToVisibleBounds=${true}>
           The check result has (probably) influenced this label vote.
         </paper-tooltip>
       </div>
@@ -539,9 +565,14 @@
   }
 
   private renderActions() {
-    const actions = [...(this.result?.actions ?? [])];
-    const fixAction = createFixAction(this, this.result);
-    if (fixAction) actions.unshift(fixAction);
+    const actions = [...(this.result?.actions ?? [])].filter(
+      action => action.name !== USEFUL && action.name !== NOT_USEFUL
+    );
+    let fixAction: Action | undefined = undefined;
+    if (!this.isExpanded) {
+      fixAction = createFixAction(this, this.result);
+      if (fixAction) actions.unshift(fixAction);
+    }
     if (actions.length === 0) return;
     const overflowItems = actions.slice(2).map(action => {
       return {...action, id: action.name};
@@ -549,24 +580,31 @@
     const disabledItems = overflowItems
       .filter(action => action.disabled)
       .map(action => action.id);
-    return html`<div class="actions">
-      ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
-      <gr-dropdown
-        id="moreActions"
-        link=""
-        vertical-offset="32"
-        horizontal-align="right"
-        @tap-item=${this.handleAction}
-        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
-          this.classList.toggle('dropdown-open', e.detail.value)}
-        ?hidden=${overflowItems.length === 0}
-        .items=${overflowItems}
-        .disabledIds=${disabledItems}
-      >
-        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
-        <span id="moreMessage">More</span>
-      </gr-dropdown>
-    </div>`;
+    return html` ${when(
+        fixAction,
+        () =>
+          html`<div class="actions-shown-on-collapsed">
+            ${this.renderAction(fixAction)}
+          </div> `
+      )}
+      <div class="actions">
+        ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
+        <gr-dropdown
+          id="moreActions"
+          link=""
+          vertical-offset="32"
+          horizontal-align="right"
+          @tap-item=${this.handleAction}
+          @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+            this.classList.toggle('dropdown-open', e.detail.value)}
+          ?hidden=${overflowItems.length === 0}
+          .items=${overflowItems}
+          .disabledIds=${disabledItems}
+        >
+          <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
+          <span id="moreMessage">More</span>
+        </gr-dropdown>
+      </div>`;
   }
 
   private handleAction(e: CustomEvent<Action>) {
@@ -585,31 +623,13 @@
     ></gr-checks-action>`;
   }
 
-  renderPrimaryActions() {
-    const primaryActions = (this.result?.actions ?? []).slice(0, 2);
-    if (primaryActions.length === 0) return;
-    return html`
-      <div class="primaryActions">${primaryActions.map(this.renderAction)}</div>
-    `;
-  }
-
-  renderSecondaryActions() {
-    const secondaryActions = (this.result?.actions ?? []).slice(2);
-    if (secondaryActions.length === 0) return;
-    return html`
-      <div class="secondaryActions">
-        ${secondaryActions.map(this.renderAction)}
-      </div>
-    `;
-  }
-
   renderTag(tag: Tag) {
     return html`<button
       class="tag ${tag.color}"
       @click=${(e: MouseEvent) => this.tagClick(e, tag.name)}
     >
       <span>${tag.name}</span>
-      <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
+      <paper-tooltip offset="5" .fitToVisibleBounds=${true}>
         ${tag.tooltip ??
         'A category tag for this check result. Click to filter.'}
       </paper-tooltip>
@@ -618,15 +638,20 @@
 }
 
 @customElement('gr-result-expanded')
-class GrResultExpanded extends LitElement {
+export class GrResultExpanded extends LitElement {
   @property({attribute: false})
   result?: RunResult;
 
   @property({type: Boolean})
   hideCodePointers = false;
 
+  @state()
+  notUsefulLabel = 'Was this helpful?';
+
   private getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   static override get styles() {
     return [
       sharedStyles,
@@ -644,6 +669,22 @@
         .message {
           padding: var(--spacing-m) 0;
         }
+        gr-checks-fix-preview {
+          margin: var(--spacing-l) 0;
+          max-width: 800px;
+        }
+        .useful {
+          display: flex;
+          justify-content: flex-end;
+          align-items: center;
+          margin: var(--spacing-s) 0;
+        }
+        .useful .title {
+          margin-right: var(--spacing-s);
+        }
+        .useful gr-checks-action {
+          display: block;
+        }
       `,
     ];
   }
@@ -668,6 +709,7 @@
           .content=${this.result.message ?? ''}
         ></gr-formatted-text>
       </gr-endpoint-decorator>
+      ${this.renderFix()} ${this.renderNotUseful()}
     `;
   }
 
@@ -704,16 +746,15 @@
       if (start) rangeText += `#${start}`;
       if (end && start !== end) rangeText += `-${end}`;
       const change = this.getChangeModel().getChange();
-      assertIsDefined(change);
+      if (!change) return undefined;
       const path = pointer.path;
-      const patchset = this.result?.patchset as PatchSetNumber | undefined;
+      const patchset = this.result?.patchset as PatchSetNumber;
       const line = pointer?.range?.start_line;
       return {
         icon: LinkIcon.CODE,
         tooltip: `${path}${rangeText}`,
-        url: createDiffUrl({
-          changeNum: change._number,
-          repo: change.project,
+        url: this.getViewModel().diffUrl({
+          basePatchNum: PARENT,
           patchNum: patchset,
           checksPatchset: patchset,
           diffView: {path, lineNum: line},
@@ -726,6 +767,40 @@
     );
   }
 
+  private renderFix() {
+    const fixSuggestionInfo = rectifyFix(
+      this.result?.fixes?.[0],
+      this.result?.checkName
+    );
+    if (!fixSuggestionInfo) return;
+    return html`
+      <gr-checks-fix-preview
+        .fixSuggestionInfo=${fixSuggestionInfo}
+        .patchSet=${this.result?.patchset}
+      ></gr-checks-fix-preview>
+    `;
+  }
+
+  private renderNotUseful() {
+    const actions = this.result?.actions ?? [];
+    const useful = actions.find(a => a.name === USEFUL);
+    const notUseful = actions.find(a => a.name === NOT_USEFUL);
+    if (!useful || !notUseful) return;
+    return html`
+      <div class="useful">
+        <div class="title">${this.notUsefulLabel}</div>
+        <gr-checks-action
+          @click=${() => (this.notUsefulLabel = 'Thanks!')}
+          .action=${useful}
+        ></gr-checks-action>
+        <gr-checks-action
+          @click=${() => (this.notUsefulLabel = 'Sorry about that')}
+          .action=${notUseful}
+        ></gr-checks-action>
+      </div>
+    `;
+  }
+
   private renderLink(link?: Link, targetBlank = true) {
     if (!link) return;
     const text = link.tooltip ?? tooltipForLink(link.icon);
@@ -801,6 +876,10 @@
   @state()
   isShowAll: Map<Category, boolean> = new Map();
 
+  /** Maintains the state of which result sections has all results expanded. */
+  @state()
+  allResultsExpanded: Map<Category, boolean> = new Map();
+
   /**
    * This is the current state of whether a section is expanded or not. As long
    * as isSectionExpandedByUser is false this will be computed by a default rule
@@ -970,6 +1049,9 @@
           padding: var(--spacing-s) var(--spacing-m);
         }
         .categoryHeader {
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-end;
           margin-top: var(--spacing-l);
           margin-left: var(--spacing-l);
           cursor: default;
@@ -1093,6 +1175,8 @@
       // moment before trying to find a child element in it.
       setTimeout(() => {
         if (el) (el as HTMLElement).focus();
+        // If the target element is a <gr-result-row>, then expand it.
+        (el as GrResultRow)?.toggleExpanded(true);
         // <gr-result-row> has display:contents and cannot be scrolled into view
         // itself. Thus we are preferring to scroll the first child into view.
         el = el?.shadowRoot?.firstElementChild ?? el;
@@ -1384,6 +1468,8 @@
     const expandedClass = expanded ? 'expanded' : 'collapsed';
 
     const isShowAll = this.isShowAll.get(category) ?? false;
+    const allExpanded = this.allResultsExpanded.get(category) ?? false;
+    const hasExpandableResults = filtered.some(computeIsExpandable);
     const resultCount = filtered.length;
     const empty = resultCount === 0 ? 'empty' : '';
     const resultLimit = isShowAll ? 1000 : 20;
@@ -1395,28 +1481,40 @@
     );
     const icon = iconFor(category);
     return html`
-      <div class=${expandedClass}>
-        <h3
-          class="categoryHeader ${catString} ${empty} heading-3"
+      <div class="${expandedClass} ${catString}">
+        <div
+          class="categoryHeader ${catString} ${empty}"
           @click=${() => this.toggleExpanded(category)}
         >
-          <gr-icon
-            class="expandIcon"
-            icon=${expanded ? 'expand_less' : 'expand_more'}
-          ></gr-icon>
-          <div class="statusIconWrapper">
+          <h3 class="left heading-3">
             <gr-icon
-              icon=${icon.name}
-              ?filled=${icon.filled}
-              class="statusIcon ${catString}"
+              class="expandIcon"
+              icon=${expanded ? 'expand_less' : 'expand_more'}
             ></gr-icon>
-            <span class="title">${catString}</span>
-            <span class="count">${this.renderCount(all, filtered)}</span>
-            <paper-tooltip offset="5"
-              >${CATEGORY_TOOLTIPS.get(category)}</paper-tooltip
+            <div class="statusIconWrapper">
+              <gr-icon
+                icon=${icon.name}
+                ?filled=${icon.filled}
+                class="statusIcon ${catString}"
+              ></gr-icon>
+              <span class="title">${catString}</span>
+              <span class="count">${this.renderCount(all, filtered)}</span>
+              <paper-tooltip offset="5"
+                >${CATEGORY_TOOLTIPS.get(category)}</paper-tooltip
+              >
+            </div>
+          </h3>
+          <div class="right">
+            <gr-button
+              link
+              ?hidden=${!expanded || !hasExpandableResults}
+              @click=${(e: MouseEvent) =>
+                this.toggleResultsExpanded(e, category)}
+            >
+              ${allExpanded ? 'Collapse All' : 'Expand All'}</gr-button
             >
           </div>
-        </h3>
+        </div>
         ${when(expanded, () =>
           this.renderResults(
             all,
@@ -1450,6 +1548,24 @@
     `;
   }
 
+  toggleResultsExpanded(e: MouseEvent, category: Category) {
+    e.preventDefault();
+    // Clicking the header row would otherwise collapse the entire section.
+    e.stopPropagation();
+    const catString = category.toString().toLowerCase();
+    const current = this.allResultsExpanded.get(category) ?? false;
+    const desired = !current;
+    this.allResultsExpanded.set(category, desired);
+    // this.allResultsExpanded stays the same object, but changes its content
+    this.requestUpdate();
+    const rows = this.shadowRoot!.querySelectorAll<GrResultRow>(
+      `.${catString} gr-result-row`
+    );
+    for (const row of rows) {
+      row.toggleExpanded(desired);
+    }
+  }
+
   toggleShowAll(category: Category) {
     const current = this.isShowAll.get(category) ?? false;
     this.isShowAll.set(category, !current);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 19205c4..f0971fc 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -5,11 +5,19 @@
  */
 import '../../test/common-test-setup';
 import './gr-checks-results';
-import {GrChecksResults, GrResultRow} from './gr-checks-results';
+import {
+  GrChecksResults,
+  GrResultExpanded,
+  GrResultRow,
+} from './gr-checks-results';
 import {html} from 'lit';
 import {fixture, assert} from '@open-wc/testing';
-import {checksModelToken} from '../../models/checks/checks-model';
-import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {checksModelToken, RunResult} from '../../models/checks/checks-model';
+import {
+  fakeRun0,
+  fakeRun1,
+  setAllFakeRuns,
+} from '../../models/checks/checks-fakes';
 import {resolve} from '../../models/dependency';
 import {createLabelInfo} from '../../test/test-data-generators';
 import {queryAndAssert, query, assertIsDefined} from '../../utils/common-util';
@@ -45,12 +53,7 @@
       /* HTML */ `
         <div class="approved label">
           <span> test-label +1 </span>
-          <paper-tooltip
-            fittovisiblebounds=""
-            offset="5"
-            role="tooltip"
-            tabindex="-1"
-          >
+          <paper-tooltip offset="5" role="tooltip" tabindex="-1">
             The check result has (probably) influenced this label vote.
           </paper-tooltip>
         </div>
@@ -95,7 +98,6 @@
             <button class="tag">
               <span> OBSOLETE </span>
               <paper-tooltip
-                fittovisiblebounds=""
                 offset="5"
                 role="tooltip"
                 tabindex="-1"
@@ -106,7 +108,6 @@
             <button class="tag">
               <span> E2E </span>
               <paper-tooltip
-                fittovisiblebounds=""
                 offset="5"
                 role="tooltip"
                 tabindex="-1"
@@ -130,6 +131,153 @@
     `
     );
   });
+
+  test('click summary, toggle expand', async () => {
+    element.isExpandable = true;
+    await element.updateComplete;
+    assert.isFalse(element.isExpanded);
+
+    const summaryDiv: HTMLElement =
+      element.shadowRoot!.querySelector('.summary')!;
+    summaryDiv.click();
+    await element.updateComplete;
+    assert.isTrue(element.isExpanded);
+
+    summaryDiv.click();
+    await element.updateComplete;
+    assert.isFalse(element.isExpanded);
+  });
+});
+
+suite('gr-result-expanded test', () => {
+  let element: GrResultExpanded;
+
+  setup(async () => {
+    element = await fixture<GrResultExpanded>(
+      html`<gr-result-expanded></gr-result-expanded>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders fake result 1 of run 0', async () => {
+    element.result = {...fakeRun0, ...fakeRun0.results![1]} as RunResult;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="links">
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" icon="download"> </gr-icon>
+            <span> Download </span>
+          </a>
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" icon="system_update"> </gr-icon>
+            <span> Download </span>
+          </a>
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" filled="" icon="image"> </gr-icon>
+            <span> Link to image </span>
+          </a>
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" filled="" icon="image"> </gr-icon>
+            <span> Link to image </span>
+          </a>
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" filled="" icon="bug_report"> </gr-icon>
+            <span> Link for reporting a problem </span>
+          </a>
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" icon="help"> </gr-icon>
+            <span> Link to help page </span>
+          </a>
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" icon="history"> </gr-icon>
+            <span> Link to result history </span>
+          </a>
+        </div>
+        <div class="links">
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" icon="open_in_new"> </gr-icon>
+            <span> Link to details </span>
+          </a>
+          <a
+            href="https://google.com"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            <gr-icon class="link" filled="" icon="image"> </gr-icon>
+            <span> Link to image </span>
+          </a>
+        </div>
+        <gr-endpoint-decorator name="check-result-expanded">
+          <gr-endpoint-param name="run"> </gr-endpoint-param>
+          <gr-endpoint-param name="result"> </gr-endpoint-param>
+          <gr-formatted-text class="message"> </gr-formatted-text>
+        </gr-endpoint-decorator>
+        <div class="useful">
+          <div class="title">Was this helpful?</div>
+          <gr-checks-action icon="thumb_up"> </gr-checks-action>
+          <gr-checks-action icon="thumb_down"> </gr-checks-action>
+        </div>
+      `
+    );
+  });
+
+  test('renders fake result 2 of run 1', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results![2]} as RunResult;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="links"></div>
+        <gr-endpoint-decorator name="check-result-expanded">
+          <gr-endpoint-param name="run"> </gr-endpoint-param>
+          <gr-endpoint-param name="result"> </gr-endpoint-param>
+          <gr-formatted-text class="message"> </gr-formatted-text>
+        </gr-endpoint-decorator>
+        <gr-checks-fix-preview> </gr-checks-fix-preview>
+        <div class="useful">
+          <div class="title">Was this helpful?</div>
+          <gr-checks-action icon="thumb_up"> </gr-checks-action>
+          <gr-checks-action icon="thumb_down"> </gr-checks-action>
+        </div>
+      `
+    );
+  });
 });
 
 suite('gr-checks-results test', () => {
@@ -269,16 +417,25 @@
           </div>
         </div>
         <div class="body">
-          <div class="expanded">
-            <h3 class="categoryHeader error heading-3">
-              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
-              <div class="statusIconWrapper">
-                <gr-icon icon="error" filled class="error statusIcon"></gr-icon>
-                <span class="title"> error </span>
-                <span class="count"> (3) </span>
-                <paper-tooltip offset="5"> </paper-tooltip>
+          <div class="error expanded">
+            <div class="categoryHeader error">
+              <h3 class="left heading-3">
+                <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+                <div class="statusIconWrapper">
+                  <gr-icon
+                    icon="error"
+                    filled
+                    class="error statusIcon"
+                  ></gr-icon>
+                  <span class="title"> error </span>
+                  <span class="count"> (3) </span>
+                  <paper-tooltip offset="5"> </paper-tooltip>
+                </div>
+              </h3>
+              <div class="right">
+                <gr-button link=""> Expand All </gr-button>
               </div>
-            </h3>
+            </div>
             <gr-result-row
               class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
             >
@@ -300,17 +457,22 @@
               <tbody></tbody>
             </table>
           </div>
-          <div class="expanded">
-            <h3 class="categoryHeader heading-3 warning">
-              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
-              <div class="statusIconWrapper">
-                <gr-icon icon="warning" filled class="warning statusIcon">
-                </gr-icon>
-                <span class="title"> warning </span>
-                <span class="count"> (1) </span>
-                <paper-tooltip offset="5"> </paper-tooltip>
+          <div class="expanded warning">
+            <div class="categoryHeader warning">
+              <h3 class="left heading-3">
+                <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+                <div class="statusIconWrapper">
+                  <gr-icon icon="warning" filled class="warning statusIcon">
+                  </gr-icon>
+                  <span class="title"> warning </span>
+                  <span class="count"> (1) </span>
+                  <paper-tooltip offset="5"> </paper-tooltip>
+                </div>
+              </h3>
+              <div class="right">
+                <gr-button link=""> Expand All </gr-button>
               </div>
-            </h3>
+            </div>
             <gr-result-row class="FAKESuperCheck" isexpandable> </gr-result-row>
             <table class="resultsTable">
               <thead>
@@ -323,28 +485,38 @@
               <tbody></tbody>
             </table>
           </div>
-          <div class="collapsed">
-            <h3 class="categoryHeader heading-3 info">
-              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
-              <div class="statusIconWrapper">
-                <gr-icon icon="info" class="info statusIcon"></gr-icon>
-                <span class="title"> info </span>
-                <span class="count"> (3) </span>
-                <paper-tooltip offset="5"> </paper-tooltip>
+          <div class="collapsed info">
+            <div class="categoryHeader info">
+              <h3 class="left heading-3">
+                <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+                <div class="statusIconWrapper">
+                  <gr-icon icon="info" class="info statusIcon"></gr-icon>
+                  <span class="title"> info </span>
+                  <span class="count"> (3) </span>
+                  <paper-tooltip offset="5"> </paper-tooltip>
+                </div>
+              </h3>
+              <div class="right">
+                <gr-button hidden="" link=""> Expand All </gr-button>
               </div>
-            </h3>
+            </div>
           </div>
-          <div class="collapsed">
-            <h3 class="categoryHeader empty heading-3 success">
-              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
-              <div class="statusIconWrapper">
-                <gr-icon icon="check_circle" class="statusIcon success">
-                </gr-icon>
-                <span class="title"> success </span>
-                <span class="count"> (0) </span>
-                <paper-tooltip offset="5"> </paper-tooltip>
+          <div class="collapsed success">
+            <div class="categoryHeader empty success">
+              <h3 class="left heading-3">
+                <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+                <div class="statusIconWrapper">
+                  <gr-icon icon="check_circle" class="statusIcon success">
+                  </gr-icon>
+                  <span class="title"> success </span>
+                  <span class="count"> (0) </span>
+                  <paper-tooltip offset="5"> </paper-tooltip>
+                </div>
+              </h3>
+              <div class="right">
+                <gr-button hidden="" link=""> Expand All </gr-button>
               </div>
-            </h3>
+            </div>
           </div>
         </div>
       `,
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index dd2b29f..797122e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -475,6 +475,7 @@
     return [
       sharedStyles,
       fontStyles,
+      formStyles,
       css`
         :host {
           display: block;
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 58b939c..16d3d3e 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -211,7 +211,6 @@
   }
 
   private renderActions() {
-    if (!this.isExpanded) return nothing;
     return html`<div class="actions">
       ${this.renderShowFixButton()}${this.renderPleaseFixButton()}
     </div>`;
@@ -237,6 +236,7 @@
   }
 
   private renderShowFixButton() {
+    if (this.isExpanded) return nothing;
     const action = createFixAction(this, this.result);
     if (!action) return nothing;
     return html`
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index b913c87..248dd62 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -31,31 +31,42 @@
     assert.shadowDom.equal(
       element,
       `
-      <div class="container font-normal warning">
-        <div class="header">
-          <div class="icon">
-            <gr-icon icon="warning" filled></gr-icon>
-          </div>
-          <div class="name">
-            <gr-hovercard-run> </gr-hovercard-run>
-            <div class="name" role="button" tabindex="0">FAKE Super Check</div>
-          </div>
-          <div class="summary">We think that you could improve this.</div>
-          <div class="message">
-            There is a lot to be said. A lot. I say, a lot.
+        <div class="container font-normal warning">
+          <div class="header">
+            <div class="icon">
+              <gr-icon icon="warning" filled></gr-icon>
+            </div>
+            <div class="name">
+              <gr-hovercard-run> </gr-hovercard-run>
+              <div class="name" role="button" tabindex="0">
+                FAKE Super Check
+              </div>
+            </div>
+            <div class="summary">We think that you could improve this.</div>
+            <div class="message">
+              There is a lot to be said. A lot. I say, a lot.
                 So please keep reading.
+            </div>
+            <div
+              aria-checked="false"
+              aria-label="Expand result row"
+              class="show-hide"
+              role="switch"
+              tabindex="0"
+            >
+              <gr-icon icon="expand_more"></gr-icon>
+            </div>
           </div>
-          <div aria-checked="false"
-               aria-label="Expand result row"
-               class="show-hide"
-               role="switch"
-               tabindex="0">
-            <gr-icon icon="expand_more"></gr-icon>
+          <div class="details">
+            <div class="actions">
+              <gr-checks-action
+                id="please-fix"
+                context="diff-fix"
+              ></gr-checks-action>
+            </div>
           </div>
         </div>
-        <div class="details"></div>
-      </div>
-    `
+      `
     );
   });
 
@@ -72,10 +83,6 @@
           <gr-result-expanded hidecodepointers=""></gr-result-expanded>
           <div class="actions">
             <gr-checks-action
-              id="show-fix"
-              context="diff-fix"
-            ></gr-checks-action>
-            <gr-checks-action
               id="please-fix"
               context="diff-fix"
             ></gr-checks-action>
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
index e9fbeda..8e99c3c 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import './gr-hovercard-run';
 import {fixture, html, assert} from '@open-wc/testing';
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index 2916f75..644871a4 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -7,12 +7,14 @@
 import '../../shared/gr-avatar/gr-avatar';
 import {getUserName} from '../../../utils/display-name-util';
 import {AccountInfo, DropdownLink, ServerInfo} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
 import {fire} from '../../../utils/event-util';
 import {DropdownContent} from '../../shared/gr-dropdown/gr-dropdown';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {customElement, property, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -30,34 +32,54 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  @property({type: Object})
+  @property({type: Boolean})
+  showMobile?: boolean;
+
+  // Private but used in test
+  @state()
   config?: ServerInfo;
 
-  @property({type: String})
-  _path = '/';
+  @state()
+  private path = '/';
 
-  @property({type: Boolean})
-  _hasAvatars = false;
+  @state()
+  private hasAvatars = false;
 
-  @property({type: String})
-  _switchAccountUrl = '';
+  @state()
+  private switchAccountUrl = '';
 
-  private readonly restApiService = getAppContext().restApiService;
+  // private but used in test
+  @state() feedbackURL = '';
+
+  // Private but used in test
+  readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      cfg => {
+        this.config = cfg;
+
+        if (cfg?.gerrit?.report_bug_url) {
+          this.feedbackURL = cfg?.gerrit.report_bug_url;
+        }
+
+        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+          this.switchAccountUrl = cfg.auth.switch_account_url;
+        } else {
+          this.switchAccountUrl = '';
+        }
+        this.hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+      }
+    );
+  }
 
   override connectedCallback() {
     super.connectedCallback();
     this.handleLocationChange();
     document.addEventListener('location-change', this.handleLocationChange);
-    this.restApiService.getConfig().then(cfg => {
-      this.config = cfg;
-
-      if (cfg && cfg.auth && cfg.auth.switch_account_url) {
-        this._switchAccountUrl = cfg.auth.switch_account_url;
-      } else {
-        this._switchAccountUrl = '';
-      }
-      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-    });
   }
 
   override disconnectedCallback() {
@@ -88,15 +110,17 @@
       link=""
       .items=${this.links}
       .topContent=${this.topContent}
-      @tap-item-shortcuts=${this._handleShortcutsTap}
+      @tap-item-shortcuts=${this.handleShortcutsTap}
       .horizontalAlign=${'right'}
     >
-      <span ?hidden=${this._hasAvatars}
-        >${this._accountName(this.account)}</span
-      >
+      ${this.showMobile && !this.hasAvatars
+        ? html`<gr-icon icon="account_circle" filled></gr-icon>`
+        : html`<span ?hidden=${this.hasAvatars}
+            >${this.accountName(this.account)}</span
+          >`}
       <gr-avatar
         .account=${this.account}
-        ?hidden=${!this._hasAvatars}
+        ?hidden=${!this.hasAvatars}
         .imageSize=${56}
         aria-label="Account avatar"
       ></gr-avatar>
@@ -104,14 +128,15 @@
   }
 
   get links(): DropdownLink[] | undefined {
-    return this._getLinks(this._switchAccountUrl, this._path);
+    return this.getLinks(this.switchAccountUrl, this.path);
   }
 
   get topContent(): DropdownContent[] | undefined {
-    return this._getTopContent(this.account);
+    return this.getTopContent(this.account);
   }
 
-  _getLinks(switchAccountUrl?: string, path?: string) {
+  // Private but used in test
+  getLinks(switchAccountUrl?: string, path?: string) {
     if (switchAccountUrl === undefined || path === undefined) {
       return undefined;
     }
@@ -121,37 +146,48 @@
     links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
     if (switchAccountUrl) {
       const replacements = {path};
-      const url = this._interpolateUrl(switchAccountUrl, replacements);
+      const url = this.interpolateUrl(switchAccountUrl, replacements);
       links.push({name: 'Switch account', url, external: true});
     }
+    if (this.showMobile && this.feedbackURL) {
+      links.push({
+        name: 'Feedback',
+        id: 'feedback',
+        url: this.feedbackURL,
+        external: true,
+        target: '_blank',
+      });
+    }
     links.push({name: 'Sign out', id: 'signout', url: '/logout'});
     return links;
   }
 
-  _getTopContent(account?: AccountInfo) {
+  // Private but used in test
+  getTopContent(account?: AccountInfo) {
     return [
-      {text: this._accountName(account), bold: true},
+      {text: this.accountName(account), bold: true},
       {text: account?.email ? account.email : ''},
     ] as DropdownContent[];
   }
 
-  _handleShortcutsTap() {
+  private handleShortcutsTap() {
     fire(this, 'show-keyboard-shortcuts', {});
   }
 
   private readonly handleLocationChange = () => {
-    this._path =
+    this.path =
       window.location.pathname + window.location.search + window.location.hash;
   };
 
-  _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+  // Private but used in test
+  interpolateUrl(url: string, replacements: {[key: string]: string}) {
     return url.replace(
       INTERPOLATE_URL_PATTERN,
       (_, p1) => replacements[p1] || ''
     );
   }
 
-  _accountName(account?: AccountInfo) {
+  private accountName(account?: AccountInfo) {
     return getUserName(this.config, account);
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
index c224a6b..06dc3f8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -78,14 +78,14 @@
 
   test('switch account', () => {
     // Missing params.
-    assert.isUndefined(element._getLinks());
-    assert.isUndefined(element._getLinks(undefined));
+    assert.isUndefined(element.getLinks());
+    assert.isUndefined(element.getLinks(undefined));
 
     // No switch account link.
-    assert.equal(element._getLinks('', '')!.length, 3);
+    assert.equal(element.getLinks('', '')!.length, 3);
 
     // Unparameterized switch account link.
-    let links = element._getLinks('/switch-account', '')!;
+    let links = element.getLinks('/switch-account', '')!;
     assert.equal(links.length, 4);
     assert.deepEqual(links[2], {
       name: 'Switch account',
@@ -94,7 +94,7 @@
     });
 
     // Parameterized switch account link.
-    links = element._getLinks('/switch-account${path}', '/c/123')!;
+    links = element.getLinks('/switch-account${path}', '/c/123')!;
     assert.equal(links.length, 4);
     assert.deepEqual(links[2], {
       name: 'Switch account',
@@ -103,13 +103,13 @@
     });
   });
 
-  test('_interpolateUrl', () => {
+  test('interpolateUrl', () => {
     const replacements = {
       foo: 'bar',
       test: 'TEST',
     };
     const interpolate = (url: string) =>
-      element._interpolateUrl(url, replacements);
+      element.interpolateUrl(url, replacements);
 
     assert.equal(interpolate('test'), 'test');
     assert.equal(interpolate('${test}'), 'TEST');
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
index fff69ef..4f3b0fb 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-error-manager';
 import {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 34950b0..d133bca 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -22,13 +22,14 @@
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {subscribe} from '../../lit/subscription-controller';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -104,6 +105,9 @@
   AuthType.CUSTOM_EXTENSION,
 ]);
 
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-main-header': GrMainHeader;
@@ -149,6 +153,15 @@
   // private but used in test
   @state() feedbackURL = '';
 
+  @state() hamburgerClose? = false;
+
+  @query('.nav-sidebar') navSidebar?: HTMLDivElement;
+
+  @query('.modelBackground') modelBackground?: HTMLDivElement;
+
+  @query('.has-collapsible.active')
+  hasCollapsibleActive?: HTMLLIElement;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -202,18 +215,44 @@
         :host {
           display: block;
         }
-        nav {
+        .hideOnDesktop {
+          display: none;
+        }
+
+        nav.hideOnMobile {
           align-items: center;
           display: flex;
         }
+        nav.hideOnMobile ul {
+          list-style: none;
+          padding-left: var(--spacing-l);
+        }
+        nav.hideOnMobile .links > li {
+          cursor: default;
+          display: inline-block;
+          padding: 0;
+          position: relative;
+        }
+
+        .mobileTitle {
+          display: none;
+        }
+
         .bigTitle {
           color: var(--header-text-color);
           font-size: var(--header-title-font-size);
+          line-height: calc(var(--header-title-font-size) * 1.2);
           text-decoration: none;
         }
         .bigTitle:hover {
           text-decoration: underline;
         }
+        .titleText,
+        .mobileTitleText {
+          /* Vertical alignment of icons and text with just block/inline display is too troublesome. */
+          display: flex;
+          align-items: center;
+        }
         .titleText::before {
           --icon-width: var(--header-icon-width, var(--header-icon-size, 0));
           --icon-height: var(--header-icon-height, var(--header-icon-size, 0));
@@ -221,27 +260,57 @@
           background-size: var(--icon-width) var(--icon-height);
           background-repeat: no-repeat;
           content: '';
-          display: inline-block;
+          /* Any direct child of a flex element implicitly has 'display: block', but let's make that explicit here. */
+          display: block;
+          width: var(--icon-width);
           height: var(--icon-height);
           /* If size or height are set, then use 'spacing-m', 0px otherwise. */
           margin-right: clamp(0px, var(--icon-height), var(--spacing-m));
-          vertical-align: text-bottom;
-          width: var(--icon-width);
         }
         .titleText::after {
+          /* The height will be determined by the line-height of the .bigTitle element. */
           content: var(--header-title-content);
           white-space: nowrap;
         }
-        ul {
-          list-style: none;
-          padding-left: var(--spacing-l);
+
+        .mobileTitleText::before {
+          --icon-width: var(
+            --header-icon-width,
+            var(--header-mobile-icon-size, var(--header-icon-size, 0))
+          );
+          --icon-height: var(
+            --header-icon-height,
+            var(--header-mobile-icon-size, var(--header-icon-size, 0))
+          );
+          background-image: var(--header-mobile-icon, var(--header-icon));
+          background-size: var(--mobile-icon-width, var(--icon-width))
+            var(--mobile-icon-height, var(--icon-height));
+          background-repeat: no-repeat;
+          content: '';
+          /* Any direct child of a flex element implicitly has 'display: block', but let's make that explicit here. */
+          display: block;
+          width: var(--mobile-icon-width, var(--icon-width));
+          height: var(--mobile-icon-height, var(--icon-height));
+          /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+          margin-right: clamp(
+            0px,
+            var(--mobile-icon-height, var(--icon-height)),
+            var(--spacing-m)
+          );
         }
-        .links > li {
-          cursor: default;
-          display: inline-block;
-          padding: 0;
-          position: relative;
+        .mobileTitleText::after {
+          /* The height will be determined by the line-height of the .bigTitle element. */
+          content: var(
+            --header-mobile-title-content,
+            var(--header-title-content)
+          );
+          white-space: nowrap;
+          text-overflow: ellipsis;
+          flex: 1;
+          overflow: hidden;
+          min-width: 0;
         }
+
         .linksTitle {
           display: inline-block;
           font-weight: var(--font-weight-bold);
@@ -257,7 +326,24 @@
           flex: 1;
           justify-content: flex-end;
         }
-        .rightItems gr-endpoint-decorator:not(:empty) {
+        .mobileRightItems {
+          align-items: center;
+          justify-content: flex-end;
+
+          display: inline-block;
+          vertical-align: middle;
+          cursor: pointer;
+          position: relative;
+          top: 0px;
+          right: 0px;
+          margin-right: 0;
+          margin-left: auto;
+          min-height: 50px;
+          padding-top: 12px;
+        }
+
+        .rightItems gr-endpoint-decorator:not(:empty),
+        .mobileRightItems gr-endpoint-decorator:not(:empty) {
           margin-left: var(--spacing-l);
         }
         gr-smart-search {
@@ -292,14 +378,19 @@
         }
         :host([loading]) .accountContainer,
         :host([loggedIn]) .loginButton,
-        :host([loggedIn]) .registerButton {
+        :host([loggedIn]) .registerButton,
+        :host([loggedIn]) .moreMenu {
           display: none;
         }
         :host([loggedIn]) .settingsButton,
         :host([loggedIn]) gr-account-dropdown {
           display: inline;
         }
+        :host:not([loggedIn]) .moreMenu {
+          display: inline;
+        }
         .accountContainer {
+          flex: 0 0 auto;
           align-items: center;
           display: flex;
           margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
@@ -332,6 +423,10 @@
           --gr-button-text-color: var(--header-text-color);
           color: var(--header-text-color);
         }
+        .hamburger-open {
+          --gr-button-text-color: var(--primary-text-color);
+          color: var(--primary-text-color);
+        }
         #mobileSearch {
           display: none;
         }
@@ -355,7 +450,158 @@
             margin-left: var(--spacing-m) !important;
           }
           gr-dropdown {
-            padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+            padding: 0 var(--spacing-m);
+          }
+          .nav-sidebar {
+            background: var(--table-header-background-color);
+            width: 200px;
+            height: 100%;
+            display: block;
+            position: fixed;
+            left: -200px;
+            top: 0px;
+            transition: left 0.25s ease;
+            margin: 0;
+            border: 0;
+            overflow-y: auto;
+            overflow-x: hidden;
+            height: 100%;
+            margin-bottom: 15px 0;
+            box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+            border-radius: 3px;
+            z-index: 2;
+          }
+          .nav-sidebar.visible {
+            left: 0px;
+            transition: left 0.25s ease;
+            width: 80%;
+            z-index: 200;
+          }
+          .mobileTitle {
+            position: relative;
+            display: block;
+            top: 10px;
+            font-size: 20px;
+            left: 100px;
+            right: 100px;
+            text-align: center;
+            text-overflow: ellipsis;
+            overflow: hidden;
+            width: 50%;
+          }
+          .nav-header {
+            display: flex;
+          }
+          .hamburger {
+            display: inline-block;
+            vertical-align: middle;
+            height: 50px;
+            cursor: pointer;
+            margin: 0;
+            position: absolute;
+            top: 0;
+            left: 0;
+            padding: 12px;
+            z-index: 200;
+          }
+          .nav-sidebar ul {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: block;
+            padding-top: 50px;
+          }
+          .nav-sidebar li {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: inline-block;
+            position: relative;
+            font-size: 14;
+            color: var(--primary-text-color);
+            display: block;
+          }
+          .cover {
+            background: rgba(0, 0, 0, 0.5);
+            position: fixed;
+            top: 0;
+            bottom: 0;
+            left: 0;
+            right: 0;
+            overflow: none;
+            z-index: 199;
+          }
+          .hideOnDesktop {
+            display: block;
+          }
+          nav.hideOnMobile {
+            display: none;
+          }
+          .nav-sidebar .menu ul {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: block;
+            padding-top: 50px;
+          }
+          .nav-sidebar .menu li {
+            list-style-type: none;
+            margin: 0;
+            padding: 0;
+            display: inline-block;
+            position: relative;
+            font-size: 14;
+            color: var(--primary-text-color);
+            display: block;
+          }
+          .nav-sidebar .menu li a {
+            padding: 15px 20px;
+            font-size: 14;
+            outline: 0;
+            display: block;
+            color: var(--primary-text-color);
+            font-weight: 600;
+          }
+          .nav-sidebar .menu li.active ul.dropdown {
+            display: block;
+          }
+          .nav-sidebar .menu li ul.dropdown {
+            position: absolute;
+            display: none;
+            width: 100%;
+            box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+            padding-top: 0;
+            position: relative;
+          }
+          .nav-sidebar .menu li ul.dropdown li {
+            display: block;
+            list-style-type: none;
+          }
+          .nav-sidebar .menu li ul.dropdown li a {
+            padding: 15px 20px;
+            font-size: 15px;
+            display: block;
+            font-weight: 400;
+            border-bottom: none;
+            padding: 10px 10px 10px 30px;
+          }
+          .nav-sidebar .menu li ul.dropdown li:last-child a {
+            border-bottom: none;
+          }
+          .nav-sidebar .menu a {
+            text-decoration: none;
+          }
+          .nav-sidebar .menu li.active:first-child a {
+            border-radius: 3px 0 0 3px;
+            border-radius: 0;
+          }
+          .nav-sidebar .menu li ul.dropdown li.active:first-child a {
+            border-radius: 0;
+          }
+          .arrow-down {
+            position: absolute;
+            top: 10px;
+            right: 10px;
           }
         }
       `,
@@ -363,35 +609,134 @@
   }
 
   override render() {
+    return html` ${this.renderDesktop()} ${this.renderMobile()} `;
+  }
+
+  private renderDesktop() {
     return html`
-  <nav>
-    <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
-      <gr-endpoint-decorator name="header-title">
-        <span class="titleText"></span>
-      </gr-endpoint-decorator>
-    </a>
-    <ul class="links">
-      ${this.computeLinks(this.userLinks, this.adminLinks, this.topMenus).map(
-        linkGroup => this.renderLinkGroup(linkGroup)
-      )}
-    </ul>
-    <div class="rightItems">
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-small-banner"
-      ></gr-endpoint-decorator>
-      <gr-smart-search id="search"></gr-smart-search>
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-top-right"
-      ></gr-endpoint-decorator>
-      <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
-        ${this.renderFeedback()}
-      </gr-endpoint-decorator>
-      </div>
-      ${this.renderAccount()}
-    </div>
-  </nav>
+      <nav class="hideOnMobile">
+        <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
+          <gr-endpoint-decorator name="header-title">
+            <div class="titleText"></div>
+          </gr-endpoint-decorator>
+        </a>
+        <ul class="links">
+          ${this.computeLinks(
+            this.userLinks,
+            this.adminLinks,
+            this.topMenus
+          ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+        </ul>
+        <div class="rightItems">
+          <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-small-banner"
+          ></gr-endpoint-decorator>
+          <gr-smart-search id="search"></gr-smart-search>
+          <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-top-right"
+          ></gr-endpoint-decorator>
+          <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+            ${this.renderFeedback()}
+          </gr-endpoint-decorator>
+          </div>
+          ${this.renderAccount()}
+        </div>
+      </nav>
+    `;
+  }
+
+  private renderMobile() {
+    const moreMenu: MainHeaderLink[] = [
+      {
+        name: this.registerText,
+        url: this.registerURL,
+      },
+      {
+        name: this.loginText,
+        url: this.loginUrl,
+      },
+    ];
+    if (!this.registerURL) {
+      moreMenu.shift();
+    }
+    if (this.feedbackURL) {
+      moreMenu.push({
+        name: 'Feedback',
+        url: this.feedbackURL,
+        external: true,
+        target: '_blank',
+      });
+    }
+
+    return html`
+      <nav class="hideOnDesktop">
+        <div class="nav-sidebar">
+          <ul class="menu">
+            ${this.computeLinks(
+              this.userLinks,
+              this.adminLinks,
+              this.topMenus
+            ).map(linkGroup => this.renderLinkGroupMobile(linkGroup))}
+          </ul>
+        </div>
+        <div class="nav-header">
+          <a
+            class="hamburger"
+            href=""
+            title="Hamburger"
+            aria-label="${!this.hamburgerClose ? 'Open' : 'Close'} hamburger"
+            role="button"
+            @click=${() => {
+              this.handleSidebar();
+            }}
+          >
+            ${!this.hamburgerClose
+              ? html`<gr-icon icon="menu" filled></gr-icon>`
+              : html`<gr-icon
+                  class="hamburger-open"
+                  icon="menu_open"
+                  filled
+                ></gr-icon>`}
+          </a>
+          <a
+            href=${`//${window.location.host}${getBaseUrl()}/`}
+            class="mobileTitle bigTitle"
+          >
+            <gr-endpoint-decorator name="header-mobile-title">
+              <div class="mobileTitleText"></div>
+            </gr-endpoint-decorator>
+          </a>
+          <div class="mobileRightItems">
+            <a
+              class="searchButton"
+              title="Search"
+              @click=${(e: Event) => {
+                this.onMobileSearchTap(e);
+              }}
+              role="button"
+              aria-label=${this.mobileSearchHidden
+                ? 'Show Searchbar'
+                : 'Hide Searchbar'}
+            >
+              <gr-icon icon="search" filled></gr-icon>
+            </a>
+            <gr-dropdown
+              class="moreMenu"
+              link=""
+              .items=${moreMenu}
+              horizontal-align="center"
+            >
+              <span class="linksTitle">
+                <gr-icon icon="more_horiz" filled></gr-icon>
+              </span>
+            </gr-dropdown>
+            ${this.renderAccountDropdown(true)}
+          </div>
+        </div>
+      </nav>
+      <div class="modelBackground" @click=${() => this.handleSidebar()}></div>
     `;
   }
 
@@ -412,6 +757,41 @@
     `;
   }
 
+  private renderLinkGroupMobile(linkGroup: MainHeaderLinkGroup) {
+    return html`
+      <li class="has-collapsible" @click=${this.handleCollapsible}>
+        <a class="main" href="" data-title=${linkGroup.title}
+          >${linkGroup.title}<gr-icon
+            icon="arrow_drop_down"
+            class="arrow-down"
+          ></gr-icon
+        ></a>
+        <ul class="dropdown">
+          ${linkGroup.links.map(link => this.renderLinkMobile(link))}
+        </ul>
+      </li>
+    `;
+  }
+
+  private renderLinkMobile(link: DropdownLink) {
+    return html`
+      <li tabindex="-1">
+        <span ?hidden=${!!link.url} tabindex="-1">${link.name}</span>
+        <a
+          class="itemAction"
+          href=${this.computeLinkURL(link)}
+          ?download=${!!link.download}
+          rel=${ifDefined(this.computeLinkRel(link) ?? undefined)}
+          target=${ifDefined(link.target ?? undefined)}
+          ?hidden=${!link.url}
+          tabindex="-1"
+          @click=${() => this.handleSidebar()}
+          >${link.name}</a
+        >
+      </li>
+    `;
+  }
+
   private renderFeedback() {
     if (!this.feedbackURL) return;
 
@@ -475,11 +855,14 @@
     `;
   }
 
-  private renderAccountDropdown() {
+  private renderAccountDropdown(showOnMobile?: boolean) {
     if (!this.account) return;
 
     return html`
-      <gr-account-dropdown .account=${this.account}></gr-account-dropdown>
+      <gr-account-dropdown
+        .account=${this.account}
+        ?showMobile=${showOnMobile}
+      ></gr-account-dropdown>
     `;
   }
 
@@ -525,7 +908,6 @@
       links.push({
         title: 'Documentation',
         links: docLinks,
-        class: 'hideOnMobile',
       });
     }
     links.push({
@@ -629,4 +1011,96 @@
     e.stopPropagation();
     fire(this, 'mobile-search', {});
   }
+
+  /**
+   * Build a URL for the given host and path. The base URL will be only added,
+   * if it is not already included in the path.
+   *
+   * TODO: Move to util handler to remove duplication.
+   * @return The scheme-relative URL.
+   */
+  private computeURLHelper(host: string, path: string) {
+    const base = path.startsWith(getBaseUrl()) ? '' : getBaseUrl();
+    return '//' + host + base + path;
+  }
+
+  /**
+   * Build a scheme-relative URL for the current host. Will include the base
+   * URL if one is present. Note: the URL will be scheme-relative but absolute
+   * with regard to the host.
+   *
+   * TODO: Move to util handler to remove duplication.
+   * @param path The path for the URL.
+   * @return The scheme-relative URL.
+   */
+  private computeRelativeURL(path: string) {
+    const host = window.location.host;
+    return this.computeURLHelper(host, path);
+  }
+
+  /**
+   * Compute the URL for a link object.
+   *
+   * Private but used in tests.
+   *
+   * TODO: Move to util handler to remove duplication.
+   */
+  private computeLinkURL(link: DropdownLink) {
+    if (typeof link.url === 'undefined') {
+      return '';
+    }
+    if (link.target || !link.url.startsWith('/')) {
+      return link.url;
+    }
+    return this.computeRelativeURL(link.url);
+  }
+
+  /**
+   * Compute the value for the rel attribute of an anchor for the given link
+   * object. If the link has a target value, then the rel must be "noopener"
+   * for security reasons.
+   * Private but used in tests.
+   *
+   * TODO: Move to util handler to remove duplication.
+   */
+  private computeLinkRel(link: DropdownLink) {
+    // Note: noopener takes precedence over external.
+    if (link.target) {
+      return REL_NOOPENER;
+    }
+    if (link.external) {
+      return REL_EXTERNAL;
+    }
+    return null;
+  }
+
+  private handleCollapsible(e: MouseEvent) {
+    const target = e.target as HTMLSpanElement;
+    if (target.hasAttribute('data-title')) {
+      if (target.parentElement?.classList.contains('active')) {
+        target.parentElement.classList.remove('active');
+      } else {
+        if (this.hasCollapsibleActive) {
+          this.hasCollapsibleActive.classList.remove('active');
+        }
+        target.parentElement?.classList.toggle('active');
+      }
+    }
+  }
+
+  private handleSidebar() {
+    this.navSidebar?.classList.toggle('visible');
+    if (!this.modelBackground?.classList.contains('cover')) {
+      if (document.getElementsByTagName('html')) {
+        document.getElementsByTagName('html')[0].style.overflow = 'hidden';
+      }
+    } else {
+      if (document.getElementsByTagName('html')) {
+        document.getElementsByTagName('html')[0].style.overflow = '';
+      }
+    }
+    this.modelBackground?.classList.toggle('cover');
+    this.hasCollapsibleActive?.classList.remove('active');
+    this.hamburgerClose = !this.hamburgerClose;
+  }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index dfb44b70..b4a0600 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -39,10 +39,10 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <nav>
+        <nav class="hideOnMobile">
           <a class="bigTitle" href="//localhost:9876/">
             <gr-endpoint-decorator name="header-title">
-              <span class="titleText"> </span>
+              <div class="titleText"></div>
             </gr-endpoint-decorator>
           </a>
           <ul class="links">
@@ -51,7 +51,7 @@
                 <span class="linksTitle" id="Changes"> Changes </span>
               </gr-dropdown>
             </li>
-            <li class="hideOnMobile">
+            <li>
               <gr-dropdown down-arrow="" horizontal-align="left" link="">
                 <span class="linksTitle" id="Documentation">Documentation</span>
               </gr-dropdown>
@@ -101,6 +101,169 @@
             </a>
           </div>
         </nav>
+        <nav class="hideOnDesktop">
+          <div class="nav-sidebar">
+            <ul class="menu">
+              <li class="has-collapsible">
+                <a class="main" data-title="Changes" href="">
+                  Changes
+                  <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+                </a>
+                <ul class="dropdown">
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Open </span>
+                    <a
+                      class="itemAction"
+                      href="//localhost:9876/q/status:open+-is:wip"
+                      tabindex="-1"
+                    >
+                      Open
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Merged </span>
+                    <a
+                      class="itemAction"
+                      href="//localhost:9876/q/status:merged"
+                      tabindex="-1"
+                    >
+                      Merged
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Abandoned </span>
+                    <a
+                      class="itemAction"
+                      href="//localhost:9876/q/status:abandoned"
+                      tabindex="-1"
+                    >
+                      Abandoned
+                    </a>
+                  </li>
+                </ul>
+              </li>
+              <li class="has-collapsible">
+                <a class="main" data-title="Documentation" href="">
+                  Documentation
+                  <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+                </a>
+                <ul class="dropdown">
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Table of Contents </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/index.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Table of Contents
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Searching </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/user-search.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Searching
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Uploading </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/user-upload.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Uploading
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Access Control </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/access-control.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Access Control
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> REST API </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/rest-api.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      REST API
+                    </a>
+                  </li>
+                  <li tabindex="-1">
+                    <span hidden="" tabindex="-1"> Project Owner Guide </span>
+                    <a
+                      class="itemAction"
+                      href="https://gerrit-review.googlesource.com/Documentation/intro-project-owner.html"
+                      rel="noopener"
+                      tabindex="-1"
+                      target="_blank"
+                    >
+                      Project Owner Guide
+                    </a>
+                  </li>
+                </ul>
+              </li>
+              <li class="has-collapsible">
+                <a class="main" data-title="Browse" href="">
+                  Browse
+                  <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+                </a>
+                <ul class="dropdown"></ul>
+              </li>
+            </ul>
+          </div>
+          <div class="nav-header">
+            <a
+              aria-label="Open hamburger"
+              class="hamburger"
+              href=""
+              role="button"
+              title="Hamburger"
+            >
+              <gr-icon filled="" icon="menu"> </gr-icon>
+            </a>
+            <a class="bigTitle mobileTitle" href="//localhost:9876/">
+              <gr-endpoint-decorator name="header-mobile-title">
+                <div class="mobileTitleText"></div>
+              </gr-endpoint-decorator>
+            </a>
+            <div class="mobileRightItems">
+              <a
+                aria-label="Hide Searchbar"
+                class="searchButton"
+                role="button"
+                title="Search"
+              >
+                <gr-icon filled="" icon="search"> </gr-icon>
+              </a>
+              <gr-dropdown class="moreMenu" horizontal-align="center" link="">
+                <span class="linksTitle">
+                  <gr-icon filled="" icon="more_horiz"> </gr-icon>
+                </span>
+              </gr-dropdown>
+            </div>
+          </div>
+        </nav>
+        <div class="modelBackground"></div>
       `
     );
   });
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 94241ea..3d21e07 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -10,7 +10,12 @@
 export interface NavigationService {
   /**
    * This is similar to letting the browser navigate to this URL when the user
-   * clicks it, or to just setting `window.location.href` directly.
+   * clicks it, or to just calling `window.location.assign()` directly.
+   *
+   * CAUTION: You should actually use `window.location.assign()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `pushState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
    *
    * This adds a new entry to the browser location history. Consier using
    * `replaceUrl()`, if you want to avoid that.
@@ -23,6 +28,11 @@
    * Navigate to this URL, but replace the current URL in the history instead of
    * adding a new one (which is what `setUrl()` would do).
    *
+   * CAUTION: You should actually use `window.location.replace()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `replaceState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
+   *
    * page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string): void;
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
index cbbcee0..c48946b 100644
--- a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-notifications-prompt';
 import {GrNotificationsPrompt} from './gr-notifications-prompt';
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 5e7cc3b..cb96d77 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -53,6 +53,11 @@
   path?: string;
 }
 
+export const UNHANDLED_URL_PATTERNS = [
+  /^\/log(in|out)(\/(.+))?$/,
+  /^\/plugins\/(.+)$/,
+];
+
 const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
 
 export class Page {
@@ -239,6 +244,18 @@
     if (this.base && orig === path && window.location.protocol !== 'file:') {
       return;
     }
+
+    // See issue 40015337: We have to make sure that we only use
+    // show()/pushState() for URLs that gr-router will actually handle.
+    // Calling pushState() tells the browser that both the previous and the
+    // next URL are handled by the same single page application with a
+    // popstate event handler. But if we call pushState() and then
+    // later `window.location.reload()` from the router and a separate page
+    // and document are loaded, then the BACK button will stop working.
+    if (UNHANDLED_URL_PATTERNS.find(pattern => pattern.test(path))) {
+      return;
+    }
+
     e.preventDefault();
     this.show(orig);
   };
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
index d194bf55..4e1f37d 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {html, assert, fixture, waitUntil} from '@open-wc/testing';
 import './gr-router';
@@ -20,14 +21,49 @@
     page.stop();
   });
 
-  test('click handler', async () => {
-    const spy = sinon.spy();
-    page.registerRoute(/\/settings/, spy);
-    const link = await fixture<HTMLAnchorElement>(
-      html`<a href="/settings"></a>`
-    );
-    link.click();
-    assert.isTrue(spy.calledOnce);
+  suite('click handler', () => {
+    const clickListener = (e: Event) => e.preventDefault();
+    let spy: sinon.SinonSpy;
+    let link: HTMLAnchorElement;
+
+    setup(async () => {
+      spy = sinon.spy();
+      link = await fixture<HTMLAnchorElement>(html`<a href="/settings"></a>`);
+
+      document.addEventListener('click', clickListener);
+    });
+
+    teardown(() => {
+      document.removeEventListener('click', clickListener);
+    });
+
+    test('click handled by specific route', async () => {
+      page.registerRoute(/\/settings/, spy);
+      link.href = '/settings';
+      link.click();
+      assert.isTrue(spy.calledOnce);
+    });
+
+    test('click handled by default route', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/something';
+      link.click();
+      assert.isTrue(spy.called);
+    });
+
+    test('click not handled for /plugins/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/plugins/gitiles';
+      link.click();
+      assert.isFalse(spy.called);
+    });
+
+    test('click not handled for /login/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/login';
+      link.click();
+      assert.isFalse(spy.called);
+    });
   });
 
   test('register route and exit', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index a3cb0ee..28ca5fd 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -46,6 +46,7 @@
   AdminViewModel,
   AdminViewState,
   PLUGIN_LIST_ROUTE,
+  SERVER_INFO_ROUTE,
 } from '../../../models/views/admin';
 import {
   AgreementViewModel,
@@ -106,6 +107,7 @@
   timeoutPromise,
 } from '../../../utils/async-util';
 import {Finalizable} from '../../../types/types';
+import {assign} from '../../../utils/location-util';
 
 // TODO: Move all patterns to view model files and use the `Route` interface,
 // which will enforce using `RegExp` in its `urlPattern` property.
@@ -120,12 +122,6 @@
   NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
   REGISTER: /^\/register(\/.*)?$/,
 
-  // Pattern for login and logout URLs intended to be passed-through. May
-  // include a return URL.
-  // TODO: Maybe this pattern and its handler can just be removed, because
-  // passing through is what the default router would eventually do anyway.
-  LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-
   // Pattern for a catchall route when no other pattern is matched.
   DEFAULT: /.*/,
 
@@ -171,8 +167,6 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-  PLUGINS: /^\/plugins\/(.+)$/,
-
   // Matches /admin/plugins with optional filter and offset.
   PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
   // Matches /admin/groups with optional filter and offset.
@@ -185,6 +179,9 @@
   // Matches /admin/repos/$REPO,tags with optional filter and offset.
   TAG_LIST: /^\/admin\/repos\/(.+),tags\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
 
+  // Matches /admin/server-info.
+  SERVER_INFO: /^\/admin\/server-info$/,
+
   QUERY: /^\/q\/(.+?)(,(\d+))?$/,
 
   /**
@@ -460,8 +457,14 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    this.setUrl(
-      '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+    // We are not using `this.getNavigation().setUrl()`, because the login
+    // page is served directly from the backend and is not part of the web
+    // app.
+    assign(
+      window.location,
+      `${basePath}/login/${encodeURIComponent(
+        returnUrl.substring(basePath.length)
+      )}`
     );
   }
 
@@ -583,6 +586,8 @@
    * page.show() eventually just calls `window.history.pushState()`.
    */
   setUrl(url: string) {
+    // TODO: Use window.location.assign() instead of page.show(), if the URL is
+    // external, i.e. not handled by the router.
     this.page.show(url);
   }
 
@@ -593,6 +598,8 @@
    * this.page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string) {
+    // TODO: Use window.location.replace() instead of page.redirect(), if the
+    // URL is external, i.e. not handled by the router.
     this.redirect(url);
   }
 
@@ -809,10 +816,6 @@
       this.handleRepoRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.PLUGINS, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.PLUGIN_LIST,
       'handlePluginListFilterRoute',
@@ -921,10 +924,6 @@
       this.handleRegisterRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.LOG_IN_OR_OUT, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.IMPROPERLY_ENCODED_PLUS,
       'handleImproperlyEncodedPlusRoute',
@@ -935,6 +934,13 @@
       this.handlePluginScreen(ctx)
     );
 
+    this.mapRouteState(
+      SERVER_INFO_ROUTE,
+      this.adminViewModel,
+      'handleServerInfoRoute',
+      true
+    );
+
     this.mapRoute(
       RoutePattern.DOCUMENTATION_SEARCH_FILTER,
       'handleDocumentationSearchRoute',
@@ -1589,14 +1595,6 @@
   }
 
   /**
-   * Handler for routes that should pass through the router and not be caught
-   * by the catchall _handleDefaultRoute handler.
-   */
-  handlePassThroughRoute() {
-    windowLocationReload();
-  }
-
-  /**
    * URL may sometimes have /+/ encoded to / /.
    * Context: Issue 6888, Issue 7100
    */
@@ -1651,10 +1649,15 @@
       this.show404();
     } else {
       // Route can be recognized by server, so we pass it to server.
-      this.handlePassThroughRoute();
+      this.windowReload();
     }
   }
 
+  // Allows stubbing in tests.
+  windowReload() {
+    windowLocationReload();
+  }
+
   private show404() {
     // Note: the app's 404 display is tightly-coupled with catching 404
     // network responses, so we simulate a 404 response status to display it.
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index fbc0338..6a86ad1 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-router';
 import {Page, PageContext} from './gr-page';
@@ -161,6 +162,7 @@
       'handlePluginListRoute',
       'handleRepoCommandsRoute',
       'handleRepoEditFileRoute',
+      'handleServerInfoRoute',
       'handleSettingsLegacyRoute',
       'handleSettingsRoute',
     ];
@@ -181,7 +183,6 @@
       'handleDocumentationSearchRedirectRoute',
       'handleLegacyLinenum',
       'handleImproperlyEncodedPlusRoute',
-      'handlePassThroughRoute',
       'handleProjectDashboardRoute',
       'handleLegacyProjectDashboardRoute',
       'handleProjectsOldRoute',
@@ -334,7 +335,7 @@
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
-    let handlePassThroughRoute: sinon.SinonStub;
+    let windowReloadStub: sinon.SinonStub;
     let redirectToLoginStub: sinon.SinonStub;
 
     async function checkUrlToState<T extends ViewState>(
@@ -367,18 +368,12 @@
       assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
     }
 
-    async function checkUrlNotMatched(url: string) {
-      handlePassThroughRoute.reset();
-      router.page.show(url);
-      await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
-    }
-
     setup(() => {
       stubRestApi('addRepoNameToCache');
       redirectStub = sinon.stub(router, 'redirect');
       redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       setStateStub = sinon.stub(router, 'setState');
-      handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+      windowReloadStub = sinon.stub(router, 'windowReload');
       router._testOnly_startRouter();
     });
 
@@ -445,7 +440,7 @@
       onExit!('', () => {}); // we left page;
 
       router.handleDefaultRoute();
-      assert.isTrue(handlePassThroughRoute.calledOnce);
+      assert.isTrue(windowReloadStub.calledOnce);
     });
 
     test('IMPROPERLY_ENCODED_PLUS', async () => {
@@ -1039,12 +1034,6 @@
       });
     });
 
-    test('LOG_IN_OR_OUT pass through', async () => {
-      // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-      await checkUrlNotMatched('/login/asdf');
-      await checkUrlNotMatched('/logout/asdf');
-    });
-
     test('PLUGIN_SCREEN', async () => {
       // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
       await checkUrlToState('/x/foo/bar', {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 98e9eba..368eb22 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -126,7 +126,11 @@
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 
-const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+// 3 types of tokens
+// 1. predicate:expression (?:[^\s":]+:\s*[^\s"]+)
+// 2. quotes with anything inside "[^"]*"
+// 3. anything else like unfinished predicate [^\s"]+
+const TOKENIZE_REGEX = /(?:(?:[^\s":]+:\s*[^\s"]+)|[^\s"]+|"[^"]*")+\s*/g;
 
 export type SuggestionProvider = (
   predicate: string,
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index f67024f..62ef654 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-search-bar';
 import {GrSearchBar} from './gr-search-bar';
@@ -256,6 +257,18 @@
       const s = await element.getSearchSuggestions('is:mergeab');
       assert.isEmpty(s);
     });
+
+    test('Autocompletes correctly second condition', async () => {
+      const s = await element.getSearchSuggestions('is:open me');
+      assert.equal(s[0].value, 'mergedafter:');
+    });
+
+    test('Autocomplete handles space before expression correctly', async () => {
+      // This previously suggested "mergedafter" (incorrectly) due to the
+      // leading space.
+      const s = await element.getSearchSuggestions('author: me');
+      assert.isEmpty(s);
+    });
   });
 
   [
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 3b679fd..5319c90 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -15,6 +15,7 @@
   PatchSetNum,
   BasePatchSetNum,
   FilePathToDiffInfoMap,
+  PatchSetNumber,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
@@ -40,8 +41,9 @@
 import {when} from 'lit/directives/when.js';
 import {Timing} from '../../../constants/reporting';
 import {changeModelToken} from '../../../models/change/change-model';
+import {getFileExtension} from '../../../utils/file-util';
 
-export interface FilePreview {
+export interface DiffPreview {
   filepath: string;
   preview: DiffInfo;
 }
@@ -54,12 +56,6 @@
   @query('#applyFixDialog')
   applyFixDialog?: GrDialog;
 
-  /** The currently observed dialog by `dialogOberserver`. */
-  observedDialog?: GrDialog;
-
-  /** The current observer observing the `observedDialog`. */
-  dialogObserver?: ResizeObserver;
-
   @query('#nextFix')
   nextFix?: GrButton;
 
@@ -72,11 +68,13 @@
   @state()
   patchNum?: PatchSetNum;
 
+  @state() latestPatchNum?: PatchSetNumber;
+
   @state()
   currentFix?: FixSuggestionInfo;
 
   @state()
-  currentPreviews: FilePreview[] = [];
+  currentPreviews: DiffPreview[] = [];
 
   @state()
   fixSuggestions?: FixSuggestionInfo[];
@@ -146,6 +144,11 @@
       () => this.getChangeModel().changeNum$,
       changeNum => (this.changeNum = changeNum)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
   }
 
   static override get styles() {
@@ -202,10 +205,6 @@
     `;
   }
 
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-  }
-
   private renderHeader() {
     return html`
       <div slot="header">${this.currentFix?.description ?? ''}</div>
@@ -224,7 +223,7 @@
     return html`<div slot="main">${items}</div>`;
   }
 
-  private renderDiff(preview: FilePreview) {
+  private renderDiff(preview: DiffPreview) {
     const diff = preview.preview;
     if (!anyLineTooLong(diff)) {
       this.syntaxLayer.process(diff);
@@ -240,13 +239,21 @@
   private renderFooter() {
     const fixCount = this.fixSuggestions?.length ?? 0;
     const reasonForDisabledApplyButton = this.computeTooltip();
-    if (fixCount < 2 && !reasonForDisabledApplyButton) return nothing;
-    return html`<div slot="footer" class="fix-picker">
-      ${when(fixCount >= 2, () =>
-        this.renderNavForMultipleSuggestedFixes(fixCount)
-      )}
-      ${this.renderWarning(reasonForDisabledApplyButton)}
-    </div>`;
+    const shouldRenderNav = fixCount >= 2;
+    const shouldRenderWarning = !!reasonForDisabledApplyButton;
+
+    if (!shouldRenderNav && !shouldRenderWarning) return nothing;
+
+    return html`
+      <div slot="footer" class="fix-picker">
+        ${when(shouldRenderNav, () =>
+          this.renderNavForMultipleSuggestedFixes(fixCount)
+        )}
+        ${when(shouldRenderWarning, () =>
+          this.renderWarning(reasonForDisabledApplyButton)
+        )}
+      </div>
+    `;
   }
 
   private renderNavForMultipleSuggestedFixes(fixCount: number) {
@@ -315,6 +322,8 @@
           fixSuggestion.replacements
         );
       } else {
+        // TODO(b/227463363) Remove once Robot Comments are deprecated.
+        // We don't use this for user suggestions or comments.fix_suggestions.
         res = await this.restApiService.getRobotCommentFixPreview(
           this.changeNum,
           this.patchNum,
@@ -381,27 +390,20 @@
 
   private computeTooltip() {
     if (!this.change || !this.patchNum) return '';
-    const latestPatchNum =
-      this.change.revisions[this.change.current_revision]._number;
-    return latestPatchNum !== this.patchNum
-      ? 'You cannot apply this fix because it is from a previous patchset'
-      : '';
+    if (this.isApplyFixLoading) return 'Fix is still loading ...';
+    return '';
   }
 
   private computeDisableApplyFixButton() {
     if (!this.change || !this.patchNum) return true;
-    const latestPatchNum =
-      this.change.revisions[this.change.current_revision]._number;
-    return this.patchNum !== latestPatchNum || this.isApplyFixLoading;
+    return this.isApplyFixLoading;
   }
 
   // visible for testing
   async handleApplyFix(e: Event) {
     if (e) e.stopPropagation();
 
-    const changeNum = this.changeNum;
-    const patchNum = this.patchNum;
-    const change = this.change;
+    const {changeNum, patchNum, change} = this;
     if (!changeNum || !patchNum || !change || !this.currentFix) {
       throw new Error('Not all required properties are set.');
     }
@@ -412,7 +414,8 @@
       res = await this.restApiService.applyFixSuggestion(
         changeNum,
         patchNum,
-        this.fixSuggestions[0].replacements
+        this.fixSuggestions[0].replacements,
+        this.latestPatchNum
       );
     } else {
       res = await this.restApiService.applyRobotFixSuggestion(
@@ -435,6 +438,9 @@
     this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
       method: 'apply-fix-dialog',
       description: this.fixSuggestions?.[0].description,
+      fileExtension: getFileExtension(
+        this.fixSuggestions?.[0].replacements?.[0].path ?? ''
+      ),
     });
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 267c569..d72a85e 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2019 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-apply-fix-dialog';
 import {
@@ -11,7 +12,7 @@
 } from '../../core/gr-navigation/gr-navigation';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {GrApplyFixDialog} from './gr-apply-fix-dialog';
-import {PatchSetNum} from '../../../types/common';
+import {PatchSetNum, PatchSetNumber} from '../../../types/common';
 import {
   createFixSuggestionInfo,
   createParsedChange,
@@ -73,6 +74,8 @@
     };
     element.changeNum = change._number;
     element.patchNum = change.revisions[change.current_revision]._number;
+    element.latestPatchNum = change.revisions[change.current_revision]
+      ._number as PatchSetNumber;
     element.change = change;
     element.diffPrefs = {
       ...createDefaultDiffPrefs(),
@@ -160,22 +163,7 @@
       await open(TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
-      assert.equal(button.getAttribute('title'), '');
-    });
-
-    test('apply fix button is disabled on older patchset', async () => {
-      element.change = element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(2),
-        current_revision: getCurrentRevision(0),
-      };
-      await open(TWO_FIXES);
-      const button = getConfirmButton();
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.equal(
-        button.getAttribute('title'),
-        'You cannot apply this fix because it is from a previous patchset'
-      );
+      assert.equal(button.getAttribute('title'), 'Fix is still loading ...');
     });
   });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
index 3e36ab5..0a0c922 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
@@ -463,7 +463,7 @@
           ...createRobotComment(),
           id: '01' as UrlEncodedCommentId,
           patch_set: 2 as RevisionPatchSetNum,
-          path: 'file/one',
+          path: 'file/1',
           side: CommentSide.PARENT,
           line: 1,
           updated: makeTime(1),
@@ -479,7 +479,7 @@
           id: '02' as UrlEncodedCommentId,
           in_reply_to: '04' as UrlEncodedCommentId,
           patch_set: 2 as RevisionPatchSetNum,
-          path: 'file/one',
+          path: 'file/1',
           unresolved: true,
           line: 1,
           updated: makeTime(3),
@@ -488,7 +488,7 @@
           ...createComment(),
           id: '03' as UrlEncodedCommentId,
           patch_set: 2 as RevisionPatchSetNum,
-          path: 'file/one',
+          path: 'file/1',
           side: CommentSide.PARENT,
           line: 2,
           updated: makeTime(1),
@@ -497,7 +497,7 @@
           ...createComment(),
           id: '04' as UrlEncodedCommentId,
           patch_set: 2 as RevisionPatchSetNum,
-          path: 'file/one',
+          path: 'file/1',
           line: 1,
           updated: makeTime(1),
         },
@@ -563,7 +563,7 @@
           side: CommentSide.PARENT,
           line: 1,
           updated: makeTime(3),
-          path: 'file/one',
+          path: 'file/1',
         },
         {
           ...createDraft(),
@@ -574,29 +574,29 @@
           // Draft gets lower timestamp than published comment, because we
           // want to test that the draft still gets sorted to the end.
           updated: makeTime(2),
-          path: 'file/one',
+          path: 'file/1',
         },
         {
           ...createDraft(),
           id: '14' as UrlEncodedCommentId,
           patch_set: 3 as RevisionPatchSetNum,
           line: 1,
-          path: 'file/two',
+          path: 'file/2',
           updated: makeTime(3),
         },
       ] as const;
       const drafts: {[path: string]: DraftInfo[]} = {
-        'file/one': [comments[11], comments[12]],
-        'file/two': [comments[13]],
+        'file/1': [comments[11], comments[12]],
+        'file/2': [comments[13]],
       };
       const robotComments: {[path: string]: RobotCommentInfo[]} = {
-        'file/one': [comments[0], comments[1]],
+        'file/1': [comments[0], comments[1]],
       };
       const commentsByFile: {[path: string]: CommentInfo[]} = {
-        'file/one': [comments[2], comments[3]],
-        'file/two': [comments[4], comments[5]],
-        'file/three': [comments[6], comments[7], comments[8]],
-        'file/four': [comments[9], comments[10]],
+        'file/1': [comments[2], comments[3]],
+        'file/2': [comments[4], comments[5]],
+        'file/3': [comments[6], comments[7], comments[8]],
+        'file/4': [comments[9], comments[10]],
       };
 
       function makeTime(mins: number) {
@@ -624,23 +624,23 @@
         patchRange.basePatchNum = PARENT;
         patchRange.patchNum = 3 as RevisionPatchSetNum;
         paths = changeComments.getPaths(patchRange);
-        assert.notProperty(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
+        assert.notProperty(paths, 'file/1');
+        assert.property(paths, 'file/2');
+        assert.property(paths, 'file/3');
+        assert.notProperty(paths, 'file/4');
 
         patchRange.patchNum = 2 as RevisionPatchSetNum;
         paths = changeComments.getPaths(patchRange);
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
+        assert.property(paths, 'file/1');
+        assert.property(paths, 'file/2');
+        assert.property(paths, 'file/3');
+        assert.notProperty(paths, 'file/4');
 
         paths = changeComments.getPaths();
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.property(paths, 'file/four');
+        assert.property(paths, 'file/1');
+        assert.property(paths, 'file/2');
+        assert.property(paths, 'file/3');
+        assert.property(paths, 'file/4');
       });
 
       test('getCommentsForPath', () => {
@@ -648,7 +648,7 @@
           basePatchNum: 1 as BasePatchSetNum,
           patchNum: 3 as RevisionPatchSetNum,
         };
-        let path = 'file/one';
+        let path = 'file/1';
         let comments = changeComments.getCommentsForPath(path, patchRange);
         assert.equal(
           comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
@@ -659,7 +659,7 @@
           0
         );
 
-        path = 'file/two';
+        path = 'file/2';
         comments = changeComments.getCommentsForPath(path, patchRange);
         assert.equal(
           comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
@@ -682,7 +682,7 @@
         );
 
         patchRange.basePatchNum = PARENT;
-        path = 'file/three';
+        path = 'file/3';
         comments = changeComments.getCommentsForPath(path, patchRange);
         assert.equal(
           comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
@@ -695,10 +695,10 @@
       });
 
       test('getAllCommentsForPath', () => {
-        let path = 'file/one';
+        let path = 'file/1';
         let comments = changeComments.getAllCommentsForPath(path);
         assert.equal(comments.length, 4);
-        path = 'file/two';
+        path = 'file/2';
         comments = changeComments.getAllCommentsForPath(path, 2 as PatchSetNum);
         assert.equal(comments.length, 1);
         const aCopyOfComments = changeComments.getAllCommentsForPath(
@@ -710,7 +710,7 @@
       });
 
       test('getAllDraftsForPath', () => {
-        const path = 'file/one';
+        const path = 'file/1';
         const drafts = changeComments.getAllDraftsForPath(path);
         assert.equal(drafts.length, 2);
       });
@@ -719,21 +719,21 @@
         assert.equal(
           changeComments.computeUnresolvedNum({
             patchNum: 2 as PatchSetNum,
-            path: 'file/one',
+            path: 'file/1',
           }),
           0
         );
         assert.equal(
           changeComments.computeUnresolvedNum({
             patchNum: 1 as PatchSetNum,
-            path: 'file/one',
+            path: 'file/1',
           }),
           0
         );
         assert.equal(
           changeComments.computeUnresolvedNum({
             patchNum: 2 as PatchSetNum,
-            path: 'file/three',
+            path: 'file/3',
           }),
           1
         );
@@ -914,21 +914,21 @@
         assert.equal(
           changeComments.computeCommentThreads({
             patchNum: 2 as PatchSetNum,
-            path: 'file/one',
+            path: 'file/1',
           }).length,
           3
         );
         assert.deepEqual(
           changeComments.computeCommentThreads({
             patchNum: 1 as PatchSetNum,
-            path: 'file/one',
+            path: 'file/1',
           }),
           []
         );
         assert.equal(
           changeComments.computeCommentThreads({
             patchNum: 2 as PatchSetNum,
-            path: 'file/three',
+            path: 'file/3',
           }).length,
           1
         );
@@ -937,15 +937,15 @@
       test('computeCommentThreads - check content', () => {
         const expectedThreads: CommentThread[] = [
           {
-            ...createCommentThread([{...comments[9], path: 'file/four'}]),
+            ...createCommentThread([{...comments[9], path: 'file/4'}]),
           },
           {
-            ...createCommentThread([{...comments[10], path: 'file/four'}]),
+            ...createCommentThread([{...comments[10], path: 'file/4'}]),
           },
         ];
         assert.deepEqual(
           changeComments.computeCommentThreads({
-            path: 'file/four',
+            path: 'file/4',
           }),
           expectedThreads
         );
@@ -955,21 +955,21 @@
         assert.equal(
           changeComments.computeDraftCount({
             patchNum: 2 as PatchSetNum,
-            path: 'file/one',
+            path: 'file/1',
           }),
           2
         );
         assert.equal(
           changeComments.computeDraftCount({
             patchNum: 1 as PatchSetNum,
-            path: 'file/one',
+            path: 'file/1',
           }),
           0
         );
         assert.equal(
           changeComments.computeDraftCount({
             patchNum: 2 as PatchSetNum,
-            path: 'file/three',
+            path: 'file/3',
           }),
           0
         );
@@ -979,79 +979,83 @@
       test('getAllPublishedComments', () => {
         let publishedComments = changeComments.getAllPublishedComments();
         assert.equal(Object.keys(publishedComments).length, 4);
-        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
-        assert.equal(Object.keys(publishedComments['file/two']).length, 2);
+        assert.equal(Object.keys(publishedComments['file/1']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/2']).length, 2);
         publishedComments = changeComments.getAllPublishedComments(
           2 as PatchSetNum
         );
-        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
-        assert.equal(Object.keys(publishedComments['file/two']).length, 1);
+        assert.equal(Object.keys(publishedComments['file/1']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/2']).length, 1);
       });
 
       test('getAllComments', () => {
         let comments = changeComments.getAllComments();
         assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments['file/one']).length, 4);
-        assert.equal(Object.keys(comments['file/two']).length, 2);
+        assert.equal(Object.keys(comments['file/1']).length, 4);
+        assert.equal(Object.keys(comments['file/2']).length, 2);
         comments = changeComments.getAllComments(false, 2 as PatchSetNum);
         assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments['file/one']).length, 4);
-        assert.equal(Object.keys(comments['file/two']).length, 1);
+        assert.equal(Object.keys(comments['file/1']).length, 4);
+        assert.equal(Object.keys(comments['file/2']).length, 1);
         // Include drafts
         comments = changeComments.getAllComments(true);
         assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments['file/one']).length, 6);
-        assert.equal(Object.keys(comments['file/two']).length, 3);
+        assert.equal(Object.keys(comments['file/1']).length, 6);
+        assert.equal(Object.keys(comments['file/2']).length, 3);
         comments = changeComments.getAllComments(true, 2 as PatchSetNum);
         assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments['file/one']).length, 6);
-        assert.equal(Object.keys(comments['file/two']).length, 1);
+        assert.equal(Object.keys(comments['file/1']).length, 6);
+        assert.equal(Object.keys(comments['file/2']).length, 1);
       });
 
       test('computeAllThreads', () => {
         const expectedThreads: CommentThread[] = [
           {
-            ...createCommentThread([{...comments[0], path: 'file/one'}]),
-          },
-          {
-            ...createCommentThread([{...comments[2], path: 'file/one'}]),
-          },
-          {
             ...createCommentThread([
-              {...comments[3], path: 'file/one'},
-              {...comments[1], path: 'file/one'},
-              {...comments[12], path: 'file/one'},
+              {...comments[3], path: 'file/1'},
+              {...comments[1], path: 'file/1'},
+              {...comments[12], path: 'file/1'},
             ]),
           },
           {
-            ...createCommentThread([{...comments[4], path: 'file/two'}]),
+            ...createCommentThread([{...comments[11], path: 'file/1'}]),
           },
           {
-            ...createCommentThread([{...comments[5], path: 'file/two'}]),
+            ...createCommentThread([{...comments[0], path: 'file/1'}]),
+          },
+          {
+            ...createCommentThread([{...comments[2], path: 'file/1'}]),
+          },
+          {
+            ...createCommentThread([{...comments[4], path: 'file/2'}]),
+          },
+          {
+            ...createCommentThread([{...comments[13], path: 'file/2'}]),
+          },
+          {
+            ...createCommentThread([{...comments[5], path: 'file/2'}]),
           },
           {
             ...createCommentThread([
-              {...comments[6], path: 'file/three'},
-              {...comments[7], path: 'file/three'},
+              {...comments[6], path: 'file/3'},
+              {...comments[7], path: 'file/3'},
             ]),
           },
           {
-            ...createCommentThread([{...comments[8], path: 'file/three'}]),
+            ...createCommentThread([{...comments[8], path: 'file/3'}]),
           },
           {
-            ...createCommentThread([{...comments[9], path: 'file/four'}]),
+            ...createCommentThread([{...comments[9], path: 'file/4'}]),
           },
           {
-            ...createCommentThread([{...comments[10], path: 'file/four'}]),
-          },
-          {
-            ...createCommentThread([{...comments[11], path: 'file/one'}]),
-          },
-          {
-            ...createCommentThread([{...comments[13], path: 'file/two'}]),
+            ...createCommentThread([{...comments[10], path: 'file/4'}]),
           },
         ];
         const threads = changeComments.getAllThreadsForChange();
+        assert.deepEqual(
+          threads.map(t => t.rootId),
+          expectedThreads.map(t => t.rootId)
+        );
         assert.deepEqual(threads, expectedThreads);
       });
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 6770d1c..29d2bf0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -98,6 +98,8 @@
 import {keyed} from 'lit/directives/keyed.js';
 import {repeat} from 'lit/directives/repeat.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
+import {Shortcut} from '../../lit/shortcut-controller';
+import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -125,6 +127,7 @@
     'line-selected': CustomEvent<LineSelectedEventDetail>;
     // Fired if being logged in is required.
     'show-auth-required': CustomEvent<{}>;
+    'reload-diff': CustomEvent<{path: string | undefined}>;
   }
 }
 
@@ -227,6 +230,8 @@
   @state()
   private revisionImage?: Base64ImageFile;
 
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
+
   // Do not use, use diff instead through the getters and setters.
   // This is not a regular @state because we need to also send the
   // 'diff-changed' event when it is changed. And if we rely on @state
@@ -326,12 +331,6 @@
       resolve(this, highlightServiceToken),
       () => getAppContext().reportingService
     );
-    this.renderPrefs = {
-      ...this.renderPrefs,
-      use_simplified_processor: this.flags.isEnabled(
-        KnownExperimentId.SIMPLIFIED_DIFF_PROCESSOR
-      ),
-    };
     this.addEventListener(
       // These are named inconsistently for a reason:
       // The create-comment event is fired to indicate that we should
@@ -345,6 +344,11 @@
     this.addEventListener('diff-context-expanded', event =>
       this.handleDiffContextExpanded(event)
     );
+    this.addEventListener('reload-diff', (e: CustomEvent) => {
+      if (e.detail.path === this.path) {
+        this.reload(false);
+      }
+    });
     subscribe(
       this,
       () => this.getBrowserModel().diffViewMode$,
@@ -497,6 +501,10 @@
         .showNewlineWarningLeft=${showNewlineWarningLeft}
         .showNewlineWarningRight=${showNewlineWarningRight}
         .useNewImageDiffUi=${useNewImageDiffUi}
+        .binaryDiffHint=${` Download commit to view (shortcut:
+              ${this.getShortcutsService().getShortcut(
+                Shortcut.OPEN_DOWNLOAD_DIALOG
+              )})`}
       >
         ${repeat(
           this.threads,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index eea8aaa..54a48b9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-diff-host';
 import {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index f228fb3..0a3392c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -3,7 +3,6 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Subscription} from 'rxjs';
 import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icon/gr-icon';
@@ -17,6 +16,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {userModelToken} from '../../../models/user/user-model';
 import {ironAnnouncerRequestAvailability} from '../../polymer-util';
+import {subscribe} from '../../lit/subscription-controller';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends LitElement {
@@ -36,28 +36,18 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
-  private subscriptions: Subscription[] = [];
-
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      x => (this.mode = x)
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
     ironAnnouncerRequestAvailability();
-    this.subscriptions.push(
-      this.getBrowserModel().diffViewMode$.subscribe(
-        diffView => (this.mode = diffView)
-      )
-    );
-  }
-
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-    super.disconnectedCallback();
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 2d51eed..43dee0a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-diff-mode-selector';
 import {GrDiffModeSelector} from './gr-diff-mode-selector';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index d580127..4457e68 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -12,7 +12,6 @@
 import {customElement, query, state} from 'lit/decorators.js';
 import {ValueChangedEvent} from '../../../types/events';
 import {modalStyles} from '../../../styles/gr-modal-styles';
-import {fireNoBubble} from '../../../utils/event-util';
 
 @customElement('gr-diff-preferences-dialog')
 export class GrDiffPreferencesDialog extends LitElement {
@@ -121,7 +120,6 @@
     assertIsDefined(this.diffPreferences, 'diffPreferences');
     assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
     await this.diffPreferences.save();
-    fireNoBubble(this, 'reload-diff-preference', {});
     this.diffPrefsModal.close();
   }
 
@@ -136,7 +134,4 @@
   interface HTMLElementTagNameMap {
     'gr-diff-preferences-dialog': GrDiffPreferencesDialog;
   }
-  interface HTMLElementEventMap {
-    'reload-diff-preference': CustomEvent<{}>;
-  }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index bc2d3b5..a5c2a99 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -92,7 +92,6 @@
 import {when} from 'lit/directives/when.js';
 import {styleMap} from 'lit/directives/style-map.js';
 import {
-  createDiffUrl,
   ChangeChildView,
   changeViewModelToken,
 } from '../../../models/views/change';
@@ -1164,10 +1163,7 @@
   private renderDialogs() {
     return html`
       <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
-      <gr-diff-preferences-dialog
-        id="diffPreferencesDialog"
-        @reload-diff-preference=${this.handleReloadingDiffPreference}
-      >
+      <gr-diff-preferences-dialog id="diffPreferencesDialog">
       </gr-diff-preferences-dialog>
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
@@ -1460,7 +1456,11 @@
     if (!newPath) return;
     if (newPath.up) return this.getChangeModel().changeUrl();
     if (!newPath.path) return;
-    return this.getChangeModel().diffUrl({path: newPath.path});
+    if (!this.patchNum) return;
+    return this.getViewModel().diffUrl({
+      diffView: {path: newPath.path},
+      patchNum: this.patchNum,
+    });
   }
 
   private goToEditFile() {
@@ -1507,20 +1507,14 @@
   }
 
   private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
-    if (!this.change) return;
-    if (!this.patchNum) return;
-    if (!this.changeNum) return;
-    if (!this.path) return;
-    const url = createDiffUrl({
-      changeNum: this.changeNum,
-      repo: this.change.project,
-      patchNum: this.patchNum,
-      basePatchNum: this.basePatchNum,
+    if (!this.path || !this.patchNum) return;
+    const url = this.getViewModel().diffUrl({
       diffView: {
         path: this.path,
         lineNum,
         leftSide,
       },
+      patchNum: this.patchNum,
     });
     history.replaceState(null, '', url);
   }
@@ -1934,10 +1928,6 @@
     this.navToFile(filesWithComments, 1, true);
   }
 
-  private handleReloadingDiffPreference() {
-    this.getUserModel().getDiffPreferences();
-  }
-
   private computeCanEdit() {
     return (
       !!this.change &&
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 93424b1..789c84d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-diff-view';
 import {
@@ -137,9 +138,9 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
 
-      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
       viewModel = testResolver(changeViewModelToken);
       viewModel.setState(createDiffViewState());
+      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
       await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
       element.path = 'some/path.txt';
       element.change = createParsedChange();
@@ -188,16 +189,18 @@
 
     test('renders', async () => {
       browserModel.setScreenWidth(0);
-      element.patchNum = 10 as RevisionPatchSetNum;
+      const patchNum = 10 as RevisionPatchSetNum;
+      element.patchNum = patchNum;
       element.basePatchNum = PARENT;
       const change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
         revisions: {
-          a: createRevision(10),
+          a: createRevision(patchNum),
         },
       };
       changeModel.updateStateChange(change);
+      viewModel.updateState({patchNum});
       element.files = getFilesFromFileList([
         'chell.go',
         'glados.txt',
@@ -1012,14 +1015,16 @@
       });
 
       test('prev/up/next links', async () => {
+        const patchNum = 10 as RevisionPatchSetNum;
         viewModel.setState({
           ...createDiffViewState(),
+          patchNum,
         });
         const change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
           revisions: {
-            a: createRevision(10),
+            a: createRevision(patchNum),
           },
         };
         changeModel.updateStateChange(change);
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index 65c6f1a..7aeda18 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../../shared/revision-info/revision-info';
 import './gr-patch-range-select';
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index ea5e9f3..c92f5bb 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-documentation-search';
 import {GrDocumentationSearch} from './gr-documentation-search';
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 1e871ce..ec8faae 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -29,7 +29,7 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
-import {createEditUrl} from '../../../models/views/change';
+import {changeViewModelToken} from '../../../models/views/change';
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {whenVisible} from '../../../utils/dom-util';
@@ -81,6 +81,8 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   static override get styles() {
@@ -431,13 +433,10 @@
       return;
     }
     assertIsDefined(this.patchNum, 'patchset number');
-    const url = createEditUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchNum,
+    const url = this.getViewModel().editUrl({
       editView: {path: this.path},
+      patchNum: this.patchNum,
     });
-
     this.getNavigation().setUrl(url);
     this.closeDialog(this.getDialogFromEvent(e));
   };
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index 0e6778a..bd6ec1cd 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-edit-controls';
 import {GrEditControls} from './gr-edit-controls';
@@ -32,6 +33,11 @@
   changeModelToken,
 } from '../../../models/change/change-model';
 import {SinonStubbedMember} from 'sinon';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -45,6 +51,13 @@
   >;
 
   setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 42 as NumericChangeId,
+      repo: 'gerrit' as RepoName,
+    });
+
     element = await fixture<GrEditControls>(html`
       <gr-edit-controls></gr-edit-controls>
     `);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 7e49a12..e343a01 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -11,6 +11,7 @@
 import {customElement, property} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {DropdownLink} from '../../../types/common';
+import {SpecialFilePath} from '../../../constants/constants';
 
 interface EditAction {
   label: string;
@@ -76,12 +77,18 @@
 
   _computeFileActions(actions: EditAction[]): DropdownLink[] {
     // TODO(kaspern): conditionally disable some actions based on file status.
-    return actions.map(action => {
-      return {
-        name: action.label,
-        id: action.id,
-      };
-    });
+    return actions
+      .filter(
+        action =>
+          this.filePath !== SpecialFilePath.COMMIT_MESSAGE ||
+          action.label === GrEditConstants.Actions.OPEN.label
+      )
+      .map(action => {
+        return {
+          name: action.label,
+          id: action.id,
+        };
+      });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
index bd27660..a406251 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-edit-file-controls';
 import {GrEditFileControls} from './gr-edit-file-controls';
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index e20d66d..a26eb42 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-editor-view';
 import {GrEditorView} from './gr-editor-view';
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 15cfda6..7929d9b 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../test/common-test-setup';
 import './gr-app';
 import {getAppContext} from '../services/app-context';
@@ -48,7 +49,7 @@
 
   setup(async () => {
     appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
-    stubElement('gr-account-dropdown', '_getTopContent');
+    stubElement('gr-account-dropdown', 'getTopContent');
     routerStartStub = sinon.stub(GrRouter.prototype, 'start');
     stubRestApi('getAccount').returns(Promise.resolve(undefined));
     stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts
index 5c15816..76b0a4d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn';
 import {fixture, html, assert} from '@open-wc/testing';
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
index cef6a8b..5164e98 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import {HookApi, PluginElement} from '../../../api/hook';
 import {PluginApi} from '../../../api/plugin';
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
index e0b792f..932dc96 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-plugin-host';
 import {GrPluginHost} from './gr-plugin-host';
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
index 8e7605d..80ae34d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
 import {stubElement} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts
index 5354ea5..e6c7100 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {GrPopupInterface} from './gr-popup-interface';
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 9c99ae0..2220d2f 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -3,20 +3,23 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import {EmailInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {grFormStyles} from '../../../styles/gr-form-styles';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
+import {deepClone} from '../../../utils/deep-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 @customElement('gr-email-editor')
 export class GrEmailEditor extends LitElement {
-  @property({type: Boolean}) hasUnsavedChanges = false;
+  @state() private originalEmails: EmailInfo[] = [];
 
   /* private but used in test */
   @state() emails: EmailInfo[] = [];
@@ -29,6 +32,21 @@
 
   readonly restApiService = getAppContext().restApiService;
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().emails$,
+      x => {
+        if (!x) return;
+        this.originalEmails = deepClone<EmailInfo[]>(x);
+        this.emails = deepClone<EmailInfo[]>(x);
+      }
+    );
+  }
+
   static override get styles() {
     return [
       sharedStyles,
@@ -82,24 +100,20 @@
     return html`<tr>
       <td class="emailColumn">${email.email}</td>
       <td class="preferredControl" @click=${this.handlePreferredControlClick}>
-        <iron-input
+        <!-- We have to use \`.checked\` rather then \`?checked\` as there
+              appears to be an issue when deleting, checked doesn't work correctly. -->
+        <input
           class="preferredRadio"
+          type="radio"
+          name="preferred"
+          .value=${email.email}
+          .checked=${email.preferred}
           @change=${this.handlePreferredChange}
-          .bindValue=${email.email}
-        >
-          <input
-            class="preferredRadio"
-            type="radio"
-            @change=${this.handlePreferredChange}
-            name="preferred"
-            ?checked=${email.preferred}
-          />
-        </iron-input>
+        />
       </td>
       <td>
         <gr-button
-          data-index=${index}
-          @click=${this.handleDeleteButton}
+          @click=${() => this.handleDeleteButton(index)}
           ?disabled=${this.checkPreferred(email.preferred)}
           class="remove-button"
           >Delete</gr-button
@@ -108,12 +122,6 @@
     </tr>`;
   }
 
-  loadData() {
-    return this.restApiService.getAccountEmails().then(emails => {
-      this.emails = emails ?? [];
-    });
-  }
-
   save() {
     const promises: Promise<unknown>[] = [];
 
@@ -127,24 +135,27 @@
       );
     }
 
-    return Promise.all(promises).then(() => {
+    return Promise.all(promises).then(async () => {
       this.emailsToRemove = [];
       this.newPreferred = '';
-      this.setHasUnsavedChanges(false);
+      await this.getUserModel().loadEmails(true);
+      this.setHasUnsavedChanges();
     });
   }
 
-  private handleDeleteButton(e: Event) {
-    const target = e.target;
-    if (!(target instanceof Element)) return;
-    const indexStr = target.getAttribute('data-index');
-    if (indexStr === null) return;
-    const index = Number(indexStr);
+  private handleDeleteButton(index: number) {
     const email = this.emails[index];
-    this.emailsToRemove = [...this.emailsToRemove, email];
+    // Don't add project to emailsToRemove if it wasn't in
+    // emails.
+    // We have to use JSON.stringify as we cloned the array
+    // so the reference is not the same.
+    const emails = this.emails.some(
+      x => JSON.stringify(email) === JSON.stringify(x)
+    );
+    if (emails) this.emailsToRemove.push(email);
     this.emails.splice(index, 1);
     this.requestUpdate();
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   private handlePreferredControlClick(e: Event) {
@@ -165,9 +176,10 @@
         this.emails[i].preferred = true;
         this.requestUpdate();
         this.newPreferred = preferred;
-        this.setHasUnsavedChanges(true);
+        this.setHasUnsavedChanges();
       } else if (this.emails[i].preferred) {
-        this.emails[i].preferred = false;
+        delete this.emails[i].preferred;
+        this.setHasUnsavedChanges();
         this.requestUpdate();
       }
     }
@@ -177,9 +189,11 @@
     return preferred ?? false;
   }
 
-  private setHasUnsavedChanges(value: boolean) {
-    this.hasUnsavedChanges = value;
-    fire(this, 'has-unsaved-changes-changed', {value});
+  private setHasUnsavedChanges() {
+    const hasUnsavedChanges =
+      JSON.stringify(this.originalEmails) !== JSON.stringify(this.emails) ||
+      this.emailsToRemove.length > 0;
+    fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index 25c9b97..fbcd494 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -3,29 +3,33 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-email-editor';
 import {GrEmailEditor} from './gr-email-editor';
 import {spyRestApi, stubRestApi} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
+import {EmailAddress} from '../../../api/rest-api';
 
 suite('gr-email-editor tests', () => {
   let element: GrEmailEditor;
+  let accountEmailStub: sinon.SinonStub;
 
   setup(async () => {
     const emails = [
-      {email: 'email@one.com'},
-      {email: 'email@two.com', preferred: true},
-      {email: 'email@three.com'},
+      {email: 'email@one.com' as EmailAddress},
+      {email: 'email@two.com' as EmailAddress, preferred: true},
+      {email: 'email@three.com' as EmailAddress},
     ];
 
-    stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
+    accountEmailStub = stubRestApi('getAccountEmails').returns(
+      Promise.resolve(emails)
+    );
 
     element = await fixture<GrEmailEditor>(
       html`<gr-email-editor></gr-email-editor>`
     );
 
-    await element.loadData();
     await element.updateComplete;
   });
 
@@ -45,20 +49,17 @@
             <tr>
               <td class="emailColumn">email@one.com</td>
               <td class="preferredControl">
-                <iron-input class="preferredRadio">
-                  <input
-                    class="preferredRadio"
-                    name="preferred"
-                    type="radio"
-                    value="email@one.com"
-                  />
-                </iron-input>
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@one.com"
+                />
               </td>
               <td>
                 <gr-button
                   aria-disabled="false"
                   class="remove-button"
-                  data-index="0"
                   role="button"
                   tabindex="0"
                 >
@@ -69,21 +70,17 @@
             <tr>
               <td class="emailColumn">email@two.com</td>
               <td class="preferredControl">
-                <iron-input class="preferredRadio">
-                  <input
-                    checked=""
-                    class="preferredRadio"
-                    name="preferred"
-                    type="radio"
-                    value="email@two.com"
-                  />
-                </iron-input>
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@two.com"
+                />
               </td>
               <td>
                 <gr-button
                   aria-disabled="true"
                   class="remove-button"
-                  data-index="1"
                   disabled=""
                   role="button"
                   tabindex="-1"
@@ -95,20 +92,17 @@
             <tr>
               <td class="emailColumn">email@three.com</td>
               <td class="preferredControl">
-                <iron-input class="preferredRadio">
-                  <input
-                    class="preferredRadio"
-                    name="preferred"
-                    type="radio"
-                    value="email@three.com"
-                  />
-                </iron-input>
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@three.com"
+                />
               </td>
               <td>
                 <gr-button
                   aria-disabled="false"
                   class="remove-button"
-                  data-index="2"
                   role="button"
                   tabindex="0"
                 >
@@ -123,6 +117,12 @@
   });
 
   test('renders', () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const rows = element
       .shadowRoot!.querySelector('table')!
       .querySelectorAll('tbody tr');
@@ -144,15 +144,21 @@
     );
     assert.isNotOk(rows[2].querySelector('gr-button')!.disabled);
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
   });
 
   test('edit preferred', () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const radios = element
       .shadowRoot!.querySelector('table')!
       .querySelectorAll<HTMLInputElement>('input[type=radio]');
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
@@ -162,7 +168,7 @@
 
     radios[0].click();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(hasUnsavedChangesSpy.called);
     assert.isOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
@@ -172,18 +178,24 @@
   });
 
   test('delete email', () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const buttons = element
       .shadowRoot!.querySelector('table')!
       .querySelectorAll('gr-button');
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
 
     buttons[2].click();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 1);
     assert.equal(element.emails.length, 2);
@@ -192,6 +204,12 @@
   });
 
   test('save changes', async () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+
     const deleteEmailSpy = spyRestApi('deleteAccountEmail');
     const setPreferredSpy = spyRestApi('setPreferredAccountEmail');
 
@@ -199,7 +217,7 @@
       .shadowRoot!.querySelector('table')!
       .querySelectorAll('tbody tr');
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(hasUnsavedChangesSpy.called);
     assert.isNotOk(element.newPreferred);
     assert.equal(element.emailsToRemove.length, 0);
     assert.equal(element.emails.length, 3);
@@ -208,16 +226,24 @@
     rows[0].querySelector('gr-button')!.click();
     rows[2].querySelector<HTMLInputElement>('input[type=radio]')!.click();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(hasUnsavedChangesSpy.called);
+    assert.isTrue(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
     assert.equal(element.newPreferred, 'email@three.com');
     assert.equal(element.emailsToRemove.length, 1);
     assert.equal(element.emailsToRemove[0].email, 'email@one.com');
     assert.equal(element.emails.length, 2);
 
+    accountEmailStub.restore();
+
+    accountEmailStub = stubRestApi('getAccountEmails').returns(
+      Promise.resolve(element.emails)
+    );
+
     await element.save();
     assert.equal(deleteEmailSpy.callCount, 1);
     assert.equal(deleteEmailSpy.getCall(0).args[0], 'email@one.com');
     assert.isTrue(setPreferredSpy.called);
     assert.equal(setPreferredSpy.getCall(0).args[0], 'email@three.com');
+    assert.isFalse(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
index 5be5b29..7192235 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-gpg-editor';
 import {
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 985f9be..0b5f952 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -9,8 +9,12 @@
 import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -23,47 +27,48 @@
   @query('#generatedPasswordModal')
   generatedPasswordModal?: HTMLDialogElement;
 
-  @property({type: String})
+  @state()
   username?: string;
 
-  @property({type: String})
+  @state()
   generatedPassword?: string;
 
-  @property({type: String})
+  @state()
   status?: string;
 
-  @property({type: String})
+  @state()
   passwordUrl: string | null = null;
 
   private readonly restApiService = getAppContext().restApiService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.loadData();
-  }
+  // Private but used in test
+  readonly getConfigModel = resolve(this, configModelToken);
 
-  loadData() {
-    const promises = [];
+  // Private but used in test
+  readonly getUserModel = resolve(this, userModelToken);
 
-    promises.push(
-      this.restApiService.getAccount().then(account => {
-        if (account) {
-          this.username = account.username;
-        }
-      })
-    );
-
-    promises.push(
-      this.restApiService.getConfig().then(info => {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      info => {
         if (info) {
           this.passwordUrl = info.auth.http_password_url || null;
         } else {
           this.passwordUrl = null;
         }
-      })
+      }
     );
-
-    return Promise.all(promises);
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => {
+        if (account) {
+          this.username = account.username;
+        }
+      }
+    );
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index 1abf8a7..d94638b 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -14,7 +14,7 @@
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {fixture, html, assert} from '@open-wc/testing';
+import {fixture, html, assert, waitUntil} from '@open-wc/testing';
 
 suite('gr-http-password tests', () => {
   let element: GrHttpPassword;
@@ -29,7 +29,12 @@
     stubRestApi('getConfig').returns(Promise.resolve(config));
 
     element = await fixture(html`<gr-http-password></gr-http-password>`);
-    await element.loadData();
+    await waitUntil(
+      () => element.getUserModel().getState().account === account
+    );
+    await waitUntil(
+      () => element.getConfigModel().getState().serverConfig === config
+    );
     await waitEventLoop();
   });
 
@@ -121,7 +126,8 @@
 
   test('with http_password_url', async () => {
     config.auth.http_password_url = 'http://example.com/';
-    await element.loadData();
+    element.passwordUrl = config.auth.http_password_url;
+    await element.updateComplete;
     assert.isNotNull(element.passwordUrl);
     assert.equal(element.passwordUrl, config.auth.http_password_url);
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
index d52b423..e96fa39 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-identities';
 import {GrIdentities} from './gr-identities';
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
new file mode 100644
index 0000000..a65c8a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -0,0 +1,626 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@polymer/iron-input/iron-input';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-select/gr-select';
+import {AccountDetailInfo, PreferencesInput} from '../../../types/common';
+import {grFormStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {convertToString} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+import {
+  AppTheme,
+  DateFormat,
+  DiffViewMode,
+  EmailFormat,
+  EmailStrategy,
+  TimeFormat,
+} from '../../../constants/constants';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {areNotificationsEnabled} from '../../../utils/worker-util';
+import {getDocUrl} from '../../../utils/url-util';
+import {configModelToken} from '../../../models/config/config-model';
+import {SuggestionsProvider} from '../../../api/suggestions';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
+/**
+ * This provides an interface to show settings for a user profile
+ * as defined in PreferencesInfo.
+ */
+@customElement('gr-preferences')
+export class GrPreferences extends LitElement {
+  @query('#themeSelect') themeSelect!: HTMLInputElement;
+
+  @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
+
+  @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
+
+  @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
+
+  @query('#emailNotificationsSelect')
+  emailNotificationsSelect!: HTMLInputElement;
+
+  @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
+
+  @query('#allowBrowserNotifications')
+  allowBrowserNotifications?: HTMLInputElement;
+
+  @query('#allowSuggestCodeWhileCommenting')
+  allowSuggestCodeWhileCommenting?: HTMLInputElement;
+
+  @query('#allowAiCommentAutocompletion')
+  allowAiCommentAutocompletion?: HTMLInputElement;
+
+  @query('#defaultBaseForMergesSelect')
+  defaultBaseForMergesSelect!: HTMLInputElement;
+
+  @query('#relativeDateInChangeTable')
+  relativeDateInChangeTable!: HTMLInputElement;
+
+  @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
+
+  @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
+
+  @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
+
+  @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
+
+  @query('#disableKeyboardShortcuts')
+  disableKeyboardShortcuts!: HTMLInputElement;
+
+  @query('#disableTokenHighlighting')
+  disableTokenHighlighting!: HTMLInputElement;
+
+  @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
+
+  @state() prefs?: PreferencesInput;
+
+  @state() private originalPrefs?: PreferencesInput;
+
+  @state() account?: AccountDetailInfo;
+
+  @state() private docsBaseUrl = '';
+
+  @state()
+  suggestionsProvider?: SuggestionsProvider;
+
+  readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  // private but used in test
+  readonly flagsService = getAppContext().flagsService;
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        this.originalPrefs = prefs;
+        this.prefs = {...prefs};
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      acc => {
+        this.account = acc;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    subscribe(
+      this,
+      () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
+      // We currently support results from only 1 provider.
+      suggestionsPlugins =>
+        (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
+    );
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      menuPageStyles,
+      grFormStyles,
+      css`
+        :host {
+          border: none;
+          margin-bottom: var(--spacing-xxl);
+        }
+        h2 {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h2);
+          font-weight: var(--font-weight-h2);
+          line-height: var(--line-height-h2);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <h2 id="Preferences" class=${this.hasUnsavedChanges() ? 'edited' : ''}>
+        Preferences
+      </h2>
+      <fieldset id="preferences">
+        <div id="preferences" class="gr-form-styles">
+          <section>
+            <label class="title" for="themeSelect">Theme</label>
+            <span class="value">
+              <gr-select
+                .bindValue=${this.prefs?.theme ?? AppTheme.AUTO}
+                @change=${() => {
+                  this.prefs!.theme = this.themeSelect.value as AppTheme;
+                  this.requestUpdate();
+                }}
+              >
+                <select id="themeSelect">
+                  <option value="AUTO">Auto (based on OS prefs)</option>
+                  <option value="LIGHT">Light</option>
+                  <option value="DARK">Dark</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="changesPerPageSelect"
+              >Changes per page</label
+            >
+            <span class="value">
+              <gr-select
+                .bindValue=${convertToString(this.prefs?.changes_per_page)}
+                @change=${() => {
+                  this.prefs!.changes_per_page = Number(
+                    this.changesPerPageSelect.value
+                  ) as 10 | 25 | 50 | 100;
+                  this.requestUpdate();
+                }}
+              >
+                <select id="changesPerPageSelect">
+                  <option value="10">10 rows per page</option>
+                  <option value="25">25 rows per page</option>
+                  <option value="50">50 rows per page</option>
+                  <option value="100">100 rows per page</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="dateTimeFormatSelect"
+              >Date/time format</label
+            >
+            <span class="value">
+              <gr-select
+                .bindValue=${convertToString(this.prefs?.date_format)}
+                @change=${() => {
+                  this.prefs!.date_format = this.dateTimeFormatSelect
+                    .value as DateFormat;
+                  this.requestUpdate();
+                }}
+              >
+                <select id="dateTimeFormatSelect">
+                  <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                  <option value="US">06/03 ; 06/03/16</option>
+                  <option value="ISO">06-03 ; 2016-06-03</option>
+                  <option value="EURO">3. Jun ; 03.06.2016</option>
+                  <option value="UK">03/06 ; 03/06/2016</option>
+                </select>
+              </gr-select>
+              <gr-select
+                .bindValue=${convertToString(this.prefs?.time_format)}
+                aria-label="Time Format"
+                @change=${() => {
+                  this.prefs!.time_format = this.timeFormatSelect
+                    .value as TimeFormat;
+                  this.requestUpdate();
+                }}
+              >
+                <select id="timeFormatSelect">
+                  <option value="HHMM_12">4:10 PM</option>
+                  <option value="HHMM_24">16:10</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="emailNotificationsSelect"
+              >Email notifications</label
+            >
+            <span class="value">
+              <gr-select
+                .bindValue=${convertToString(this.prefs?.email_strategy)}
+                @change=${() => {
+                  this.prefs!.email_strategy = this.emailNotificationsSelect
+                    .value as EmailStrategy;
+                  this.requestUpdate();
+                }}
+              >
+                <select id="emailNotificationsSelect">
+                  <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                  <option value="ENABLED">Only comments left by others</option>
+                  <option value="ATTENTION_SET_ONLY">
+                    Only when I am in the attention set
+                  </option>
+                  <option value="DISABLED">None</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="emailFormatSelect">Email format</label>
+            <span class="value">
+              <gr-select
+                .bindValue=${convertToString(this.prefs?.email_format)}
+                @change=${() => {
+                  this.prefs!.email_format = this.emailFormatSelect
+                    .value as EmailFormat;
+                  this.requestUpdate();
+                }}
+              >
+                <select id="emailFormatSelect">
+                  <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                  <option value="PLAINTEXT">Plaintext only</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          ${this.renderBrowserNotifications()}
+          ${this.renderGenerateSuggestionWhenCommenting()}
+          ${this.renderAiCommentAutocompletion()}
+          ${this.renderDefaultBaseForMerges()}
+          <section>
+            <label class="title" for="relativeDateInChangeTable"
+              >Show Relative Dates In Changes Table</label
+            >
+            <span class="value">
+              <input
+                id="relativeDateInChangeTable"
+                type="checkbox"
+                ?checked=${this.prefs?.relative_date_in_change_table}
+                @change=${() => {
+                  this.prefs!.relative_date_in_change_table =
+                    this.relativeDateInChangeTable.checked;
+                  this.requestUpdate();
+                }}
+              />
+            </span>
+          </section>
+          <section>
+            <span class="title">Diff view</span>
+            <span class="value">
+              <gr-select
+                .bindValue=${convertToString(this.prefs?.diff_view)}
+                @change=${() => {
+                  this.prefs!.diff_view = this.diffViewSelect
+                    .value as DiffViewMode;
+                  this.requestUpdate();
+                }}
+              >
+                <select id="diffViewSelect">
+                  <option value="SIDE_BY_SIDE">Side by side</option>
+                  <option value="UNIFIED_DIFF">Unified diff</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <label for="showSizeBarsInFileList" class="title"
+              >Show size bars in file list</label
+            >
+            <span class="value">
+              <input
+                id="showSizeBarsInFileList"
+                type="checkbox"
+                ?checked=${this.prefs?.size_bar_in_change_table}
+                @change=${() => {
+                  this.prefs!.size_bar_in_change_table =
+                    this.showSizeBarsInFileList.checked;
+                  this.requestUpdate();
+                }}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="publishCommentsOnPush" class="title"
+              >Publish comments on push</label
+            >
+            <span class="value">
+              <input
+                id="publishCommentsOnPush"
+                type="checkbox"
+                ?checked=${this.prefs?.publish_comments_on_push}
+                @change=${() => {
+                  this.prefs!.publish_comments_on_push =
+                    this.publishCommentsOnPush.checked;
+                  this.requestUpdate();
+                }}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="workInProgressByDefault" class="title"
+              >Set new changes to "work in progress" by default</label
+            >
+            <span class="value">
+              <input
+                id="workInProgressByDefault"
+                type="checkbox"
+                ?checked=${this.prefs?.work_in_progress_by_default}
+                @change=${() => {
+                  this.prefs!.work_in_progress_by_default =
+                    this.workInProgressByDefault.checked;
+                  this.requestUpdate();
+                }}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="disableKeyboardShortcuts" class="title"
+              >Disable all keyboard shortcuts</label
+            >
+            <span class="value">
+              <input
+                id="disableKeyboardShortcuts"
+                type="checkbox"
+                ?checked=${this.prefs?.disable_keyboard_shortcuts}
+                @change=${() => {
+                  this.prefs!.disable_keyboard_shortcuts =
+                    this.disableKeyboardShortcuts.checked;
+                  this.requestUpdate();
+                }}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="disableTokenHighlighting" class="title"
+              >Disable token highlighting on hover</label
+            >
+            <span class="value">
+              <input
+                id="disableTokenHighlighting"
+                type="checkbox"
+                ?checked=${this.prefs?.disable_token_highlighting}
+                @change=${() => {
+                  this.prefs!.disable_token_highlighting =
+                    this.disableTokenHighlighting.checked;
+                  this.requestUpdate();
+                }}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="insertSignedOff" class="title">
+              Insert Signed-off-by Footer For Inline Edit Changes
+            </label>
+            <span class="value">
+              <input
+                id="insertSignedOff"
+                type="checkbox"
+                ?checked=${this.prefs?.signed_off_by}
+                @change=${() => {
+                  this.prefs!.signed_off_by = this.insertSignedOff.checked;
+                  this.requestUpdate();
+                }}
+              />
+            </span>
+          </section>
+        </div>
+        <gr-button
+          id="savePrefs"
+          @click=${async () => {
+            await this.save();
+          }}
+          ?disabled=${!this.hasUnsavedChanges()}
+          >Save changes</gr-button
+        >
+      </fieldset>
+    `;
+  }
+
+  // When the experiment is over, move this back to render(),
+  // removing this function.
+  private renderBrowserNotifications() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
+      return nothing;
+    if (
+      !this.flagsService.isEnabled(
+        KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+      ) &&
+      !areNotificationsEnabled(this.account)
+    )
+      return nothing;
+    return html` <section id="allowBrowserNotificationsSection">
+      <div class="title">
+        <label for="allowBrowserNotifications"
+          >Allow browser notifications</label
+        >
+        <a
+          href=${getDocUrl(
+            this.docsBaseUrl,
+            'user-attention-set.html#_browser_notifications'
+          )}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <gr-icon icon="help" title="read documentation"></gr-icon>
+        </a>
+      </div>
+      <span class="value">
+        <input
+          id="allowBrowserNotifications"
+          type="checkbox"
+          ?checked=${this.prefs?.allow_browser_notifications}
+          @change=${() => {
+            this.prefs!.allow_browser_notifications =
+              this.allowBrowserNotifications!.checked;
+            this.requestUpdate();
+          }}
+        />
+      </span>
+    </section>`;
+  }
+
+  // When the experiment is over, move this back to render(),
+  // removing this function.
+  private renderGenerateSuggestionWhenCommenting() {
+    if (!this.suggestionsProvider) return nothing;
+    return html`
+      <section id="allowSuggestCodeWhileCommentingSection">
+        <div class="title">
+          <label for="allowSuggestCodeWhileCommenting"
+            >AI suggested fixes while commenting</label
+          >
+          <a
+            href=${this.suggestionsProvider.getDocumentationLink?.() ||
+            getDocUrl(
+              this.docsBaseUrl,
+              'user-suggest-edits.html#_generate_suggestion'
+            )}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
+        <span class="value">
+          <input
+            id="allowSuggestCodeWhileCommenting"
+            type="checkbox"
+            ?checked=${this.prefs?.allow_suggest_code_while_commenting}
+            @change=${() => {
+              this.prefs!.allow_suggest_code_while_commenting =
+                this.allowSuggestCodeWhileCommenting!.checked;
+              this.requestUpdate();
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  // When the experiment is over, move this back to render(),
+  // removing this function.
+  private renderAiCommentAutocompletion() {
+    if (
+      !this.flagsService.isEnabled(KnownExperimentId.COMMENT_AUTOCOMPLETION) ||
+      !this.suggestionsProvider
+    )
+      return nothing;
+    return html`
+      <section id="allowAiCommentAutocompletionSection">
+        <div class="title">
+          <label for="allowAiCommentAutocompletion"
+            >AI suggested text completions while commenting</label
+          >
+        </div>
+        <span class="value">
+          <input
+            id="allowAiCommentAutocompletion"
+            type="checkbox"
+            ?checked=${this.prefs?.allow_autocompleting_comments}
+            @change=${() => {
+              this.prefs!.allow_autocompleting_comments =
+                this.allowAiCommentAutocompletion!.checked;
+              this.requestUpdate();
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  // When this is fixed and can be re-enabled, move this back to render()
+  // and remove function.
+  private renderDefaultBaseForMerges() {
+    if (!this.prefs?.default_base_for_merges) return nothing;
+    return nothing;
+    // TODO: Re-enable respecting the default_base_for_merges preference.
+    // See corresponding TODO in change-model.
+    // return html`
+    //   <section>
+    //     <span class="title">Default Base For Merges</span>
+    //     <span class="value">
+    //       <gr-select
+    //         .bindValue=${convertToString(
+    //           this.prefs?.default_base_for_merges
+    //         )}
+    //         @change=${() => {
+    //           this.prefs!.default_base_for_merges = this
+    //             .defaultBaseForMergesSelect.value as DefaultBase;
+    //           this.requestUpdate();
+    //         }}
+    //       >
+    //         <select id="defaultBaseForMergesSelect">
+    //           <option value="AUTO_MERGE">Auto Merge</option>
+    //           <option value="FIRST_PARENT">First Parent</option>
+    //         </select>
+    //       </gr-select>
+    //     </span>
+    //   </section>
+    // `;
+  }
+
+  // private but used in test
+  hasUnsavedChanges() {
+    // We have to wrap boolean values in Boolean() to ensure undefined values
+    // use false rather than undefined.
+    return (
+      this.originalPrefs?.theme !== this.prefs?.theme ||
+      this.originalPrefs?.changes_per_page !== this.prefs?.changes_per_page ||
+      this.originalPrefs?.date_format !== this.prefs?.date_format ||
+      this.originalPrefs?.time_format !== this.prefs?.time_format ||
+      this.originalPrefs?.email_strategy !== this.prefs?.email_strategy ||
+      this.originalPrefs?.email_format !== this.prefs?.email_format ||
+      Boolean(this.originalPrefs?.allow_browser_notifications) !==
+        Boolean(this.prefs?.allow_browser_notifications) ||
+      Boolean(this.originalPrefs?.allow_suggest_code_while_commenting) !==
+        Boolean(this.prefs?.allow_suggest_code_while_commenting) ||
+      Boolean(this.originalPrefs?.allow_autocompleting_comments) !==
+        Boolean(this.prefs?.allow_autocompleting_comments) ||
+      this.originalPrefs?.default_base_for_merges !==
+        this.prefs?.default_base_for_merges ||
+      Boolean(this.originalPrefs?.relative_date_in_change_table) !==
+        Boolean(this.prefs?.relative_date_in_change_table) ||
+      this.originalPrefs?.diff_view !== this.prefs?.diff_view ||
+      Boolean(this.originalPrefs?.size_bar_in_change_table) !==
+        Boolean(this.prefs?.size_bar_in_change_table) ||
+      Boolean(this.originalPrefs?.publish_comments_on_push) !==
+        Boolean(this.prefs?.publish_comments_on_push) ||
+      Boolean(this.originalPrefs?.work_in_progress_by_default) !==
+        Boolean(this.prefs?.work_in_progress_by_default) ||
+      Boolean(this.originalPrefs?.disable_keyboard_shortcuts) !==
+        Boolean(this.prefs?.disable_keyboard_shortcuts) ||
+      Boolean(this.originalPrefs?.disable_token_highlighting) !==
+        Boolean(this.prefs?.disable_token_highlighting) ||
+      Boolean(this.originalPrefs?.signed_off_by) !==
+        Boolean(this.prefs?.signed_off_by)
+    );
+  }
+
+  async save() {
+    if (!this.prefs) return;
+    await this.getUserModel().updatePreferences(this.prefs);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-preferences': GrPreferences;
+  }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
new file mode 100644
index 0000000..818af06
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
@@ -0,0 +1,454 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-preferences';
+import {
+  queryAll,
+  queryAndAssert,
+  stubFlags,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {GrPreferences} from './gr-preferences';
+import {PreferencesInfo, TopMenuItemInfo} from '../../../types/common';
+import {
+  AppTheme,
+  DateFormat,
+  DefaultBase,
+  DiffViewMode,
+  EmailFormat,
+  EmailStrategy,
+  TimeFormat,
+  createDefaultPreferences,
+} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import {
+  createAccountDetailWithId,
+  createPreferences,
+} from '../../../test/test-data-generators';
+
+suite('gr-preferences tests', () => {
+  let element: GrPreferences;
+  let preferences: PreferencesInfo;
+
+  function valueOf(title: string, id: string): Element {
+    const sections = queryAll(element, `#${id} section`) ?? [];
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl?.textContent?.trim() === title) {
+        const el = sections[i].querySelector('.value');
+        if (el) return el;
+      }
+    }
+    assert.fail(`element with title ${title} not found`);
+  }
+
+  setup(async () => {
+    preferences = {
+      ...createPreferences(),
+      changes_per_page: 25,
+      theme: AppTheme.LIGHT,
+      date_format: DateFormat.UK,
+      time_format: TimeFormat.HHMM_12,
+      diff_view: DiffViewMode.UNIFIED,
+      email_strategy: EmailStrategy.ENABLED,
+      email_format: EmailFormat.HTML_PLAINTEXT,
+      default_base_for_merges: DefaultBase.FIRST_PARENT,
+      relative_date_in_change_table: false,
+      size_bar_in_change_table: true,
+      my: [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+      ] as TopMenuItemInfo[],
+      change_table: [],
+    };
+
+    stubRestApi('getPreferences').returns(Promise.resolve(preferences));
+
+    element = await fixture(html`<gr-preferences></gr-preferences>`);
+
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h2 id="Preferences">Preferences</h2>
+        <fieldset id="preferences">
+          <div class="gr-form-styles" id="preferences">
+            <section>
+              <label class="title" for="themeSelect"> Theme </label>
+              <span class="value">
+                <gr-select>
+                  <select id="themeSelect">
+                    <option value="AUTO">Auto (based on OS prefs)</option>
+                    <option value="LIGHT">Light</option>
+                    <option value="DARK">Dark</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="changesPerPageSelect">
+                Changes per page
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="changesPerPageSelect">
+                    <option value="10">10 rows per page</option>
+                    <option value="25">25 rows per page</option>
+                    <option value="50">50 rows per page</option>
+                    <option value="100">100 rows per page</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="dateTimeFormatSelect">
+                Date/time format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="dateTimeFormatSelect">
+                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                    <option value="US">06/03 ; 06/03/16</option>
+                    <option value="ISO">06-03 ; 2016-06-03</option>
+                    <option value="EURO">3. Jun ; 03.06.2016</option>
+                    <option value="UK">03/06 ; 03/06/2016</option>
+                  </select>
+                </gr-select>
+                <gr-select aria-label="Time Format">
+                  <select id="timeFormatSelect">
+                    <option value="HHMM_12">4:10 PM</option>
+                    <option value="HHMM_24">16:10</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailNotificationsSelect">
+                Email notifications
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailNotificationsSelect">
+                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                    <option value="ENABLED">
+                      Only comments left by others
+                    </option>
+                    <option value="ATTENTION_SET_ONLY">
+                      Only when I am in the attention set
+                    </option>
+                    <option value="DISABLED">None</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailFormatSelect">
+                Email format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailFormatSelect">
+                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                    <option value="PLAINTEXT">Plaintext only</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="relativeDateInChangeTable">
+                Show Relative Dates In Changes Table
+              </label>
+              <span class="value">
+                <input id="relativeDateInChangeTable" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <span class="title"> Diff view </span>
+              <span class="value">
+                <gr-select>
+                  <select id="diffViewSelect">
+                    <option value="SIDE_BY_SIDE">Side by side</option>
+                    <option value="UNIFIED_DIFF">Unified diff</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showSizeBarsInFileList">
+                Show size bars in file list
+              </label>
+              <span class="value">
+                <input checked="" id="showSizeBarsInFileList" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="publishCommentsOnPush">
+                Publish comments on push
+              </label>
+              <span class="value">
+                <input id="publishCommentsOnPush" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="workInProgressByDefault">
+                Set new changes to "work in progress" by default
+              </label>
+              <span class="value">
+                <input id="workInProgressByDefault" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableKeyboardShortcuts">
+                Disable all keyboard shortcuts
+              </label>
+              <span class="value">
+                <input id="disableKeyboardShortcuts" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableTokenHighlighting">
+                Disable token highlighting on hover
+              </label>
+              <span class="value">
+                <input id="disableTokenHighlighting" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="insertSignedOff">
+                Insert Signed-off-by Footer For Inline Edit Changes
+              </label>
+              <span class="value">
+                <input id="insertSignedOff" type="checkbox" />
+              </span>
+            </section>
+          </div>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            id="savePrefs"
+            role="button"
+            tabindex="-1"
+          >
+            Save changes
+          </gr-button>
+        </fieldset>
+      `
+    );
+  });
+
+  test('allow browser notifications', async () => {
+    stubFlags('isEnabled').returns(true);
+    element.account = createAccountDetailWithId();
+    await element.updateComplete;
+    assert.dom.equal(
+      queryAndAssert(element, '#allowBrowserNotificationsSection'),
+      /* HTML */ `<section id="allowBrowserNotificationsSection">
+        <div class="title">
+          <label for="allowBrowserNotifications">
+            Allow browser notifications
+          </label>
+          <a
+            href="/Documentation/user-attention-set.html#_browser_notifications"
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            <gr-icon icon="help" title="read documentation"> </gr-icon>
+          </a>
+        </div>
+        <span class="value">
+          <input checked="" id="allowBrowserNotifications" type="checkbox" />
+        </span>
+      </section>`
+    );
+  });
+
+  test('input values match preferences', () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(
+      Number(
+        (
+          valueOf('Changes per page', 'preferences')!
+            .firstElementChild as GrSelect
+        ).bindValue
+      ),
+      preferences.changes_per_page
+    );
+    assert.equal(
+      (valueOf('Theme', 'preferences').firstElementChild as GrSelect).bindValue,
+      preferences.theme
+    );
+    assert.equal(
+      (
+        valueOf('Date/time format', 'preferences')!
+          .firstElementChild as GrSelect
+      ).bindValue,
+      preferences.date_format
+    );
+    assert.equal(
+      (valueOf('Date/time format', 'preferences')!.lastElementChild as GrSelect)
+        .bindValue,
+      preferences.time_format
+    );
+    assert.equal(
+      (
+        valueOf('Email notifications', 'preferences')!
+          .firstElementChild as GrSelect
+      ).bindValue,
+      preferences.email_strategy
+    );
+    assert.equal(
+      (valueOf('Email format', 'preferences')!.firstElementChild as GrSelect)
+        .bindValue,
+      preferences.email_format
+    );
+    assert.equal(
+      (
+        valueOf('Show Relative Dates In Changes Table', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (valueOf('Diff view', 'preferences')!.firstElementChild as GrSelect)
+        .bindValue,
+      preferences.diff_view
+    );
+    assert.equal(
+      (
+        valueOf('Show size bars in file list', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      true
+    );
+    assert.equal(
+      (
+        valueOf('Publish comments on push', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (
+        valueOf(
+          'Set new changes to "work in progress" by default',
+          'preferences'
+        )!.firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (
+        valueOf('Disable token highlighting on hover', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (
+        valueOf(
+          'Insert Signed-off-by Footer For Inline Edit Changes',
+          'preferences'
+        )!.firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+
+    assert.isFalse(element.hasUnsavedChanges());
+  });
+
+  test('save changes', async () => {
+    assert.equal(element.prefs?.theme, AppTheme.LIGHT);
+
+    const themeSelect = valueOf('Theme', 'preferences')
+      .firstElementChild as GrSelect;
+    themeSelect.bindValue = AppTheme.DARK;
+
+    themeSelect.dispatchEvent(
+      new CustomEvent('change', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    const publishOnPush = valueOf('Publish comments on push', 'preferences')!
+      .firstElementChild! as HTMLSpanElement;
+
+    publishOnPush.click();
+
+    assert.isTrue(element.hasUnsavedChanges());
+
+    const savePrefStub = stubRestApi('savePreferences').resolves(
+      element.prefs as PreferencesInfo
+    );
+
+    await element.save();
+
+    // Wait for model state update, since this is not awaited by element.save()
+    await waitUntil(
+      () =>
+        element.getUserModel().getState().preferences?.theme === AppTheme.DARK
+    );
+    await waitUntil(
+      () => element.getUserModel().getState().preferences?.my === preferences.my
+    );
+    await waitUntil(
+      () =>
+        element.getUserModel().getState().preferences
+          ?.publish_comments_on_push === true
+    );
+
+    assert.isTrue(savePrefStub.called);
+    assert.isFalse(element.hasUnsavedChanges());
+  });
+
+  test('publish comments on push', async () => {
+    assert.isFalse(element.hasUnsavedChanges());
+
+    const publishCommentsOnPush = valueOf(
+      'Publish comments on push',
+      'preferences'
+    )!.firstElementChild! as HTMLSpanElement;
+    publishCommentsOnPush.click();
+
+    assert.isTrue(element.hasUnsavedChanges());
+
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.publish_comments_on_push, true);
+      return Promise.resolve(createDefaultPreferences());
+    });
+
+    // Save the change.
+    await element.save();
+    assert.isFalse(element.hasUnsavedChanges());
+  });
+
+  test('set new changes work-in-progress', async () => {
+    assert.isFalse(element.hasUnsavedChanges());
+
+    const newChangesWorkInProgress = valueOf(
+      'Set new changes to "work in progress" by default',
+      'preferences'
+    )!.firstElementChild! as HTMLSpanElement;
+    newChangesWorkInProgress.click();
+
+    assert.isTrue(element.hasUnsavedChanges());
+
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.work_in_progress_by_default, true);
+      return Promise.resolve(createDefaultPreferences());
+    });
+
+    // Save the change.
+    await element.save();
+    assert.isFalse(element.hasUnsavedChanges());
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
deleted file mode 100644
index c7118f7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {grFormStyles} from '../../../styles/gr-form-styles';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-settings-item': GrSettingsItem;
-  }
-}
-
-@customElement('gr-settings-item')
-export class GrSettingsItem extends LitElement {
-  @property({type: String})
-  anchor?: string;
-
-  @property({type: String})
-  override title = '';
-
-  static override get styles() {
-    return [
-      grFormStyles,
-      css`
-        :host {
-          display: block;
-          margin-bottom: var(--spacing-xxl);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    const anchor = this.anchor ?? '';
-    return html`<h2 id=${anchor} class="heading-2">${this.title}</h2>`;
-  }
-}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
deleted file mode 100644
index 6c83bea..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-settings-menu-item': GrSettingsMenuItem;
-  }
-}
-
-@customElement('gr-settings-menu-item')
-export class GrSettingsMenuItem extends LitElement {
-  @property({type: String})
-  href?: string;
-
-  @property({type: String})
-  override title = '';
-
-  static override get styles() {
-    return [sharedStyles, pageNavStyles];
-  }
-
-  override render() {
-    const href = this.href ?? '';
-    return html` <div class="navStyles">
-      <li><a href=${href}>${this.title}</a></li>
-    </div>`;
-  }
-}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 29ece75..d4d0ad1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -21,6 +21,7 @@
 import '../gr-http-password/gr-http-password';
 import '../gr-identities/gr-identities';
 import '../gr-menu-editor/gr-menu-editor';
+import '../gr-preferences/gr-preferences';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
 import '../../shared/gr-dialog/gr-dialog';
@@ -37,19 +38,10 @@
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
-import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {fire, fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {
-  DateFormat,
-  DefaultBase,
-  DiffViewMode,
-  EmailFormat,
-  EmailStrategy,
-  AppTheme,
-  TimeFormat,
-} from '../../../constants/constants';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
-import {LitElement, css, html, nothing} from 'lit';
+import {LitElement, css, html} from 'lit';
 import {customElement, query, queryAsync, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {paperStyles} from '../../../styles/gr-paper-styles';
@@ -58,29 +50,24 @@
 import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {grFormStyles} from '../../../styles/gr-form-styles';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {settingsViewModelToken} from '../../../models/views/settings';
-import {areNotificationsEnabled} from '../../../utils/worker-util';
 import {
   changeTablePrefs,
   userModelToken,
 } from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getDocUrl, rootUrl} from '../../../utils/url-util';
-import {configModelToken} from '../../../models/config/config-model';
-import {SuggestionsProvider} from '../../../api/suggestions';
-import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {rootUrl} from '../../../utils/url-util';
 
 const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
 
-enum CopyPrefsDirection {
-  PrefsToLocalPrefs,
-  LocalPrefsToPrefs,
-}
-
+/**
+ * This provides an interface to show all settings for a user profile.
+ * In most cases a individual module is used per setting to make
+ * code more readable. In other cases, it is created within this module.
+ */
 @customElement('gr-settings-view')
 export class GrSettingsView extends LitElement {
   /**
@@ -94,6 +81,9 @@
   @query('#confirm-account-deletion')
   private deleteAccountConfirmationDialog?: HTMLDialogElement;
 
+  @query('#dump-account-state')
+  private dumpAccountStateConfirmationDialog?: HTMLDialogElement;
+
   @query('#watchedProjectsEditor', true)
   watchedProjectsEditor!: GrWatchedProjectsEditor;
 
@@ -109,64 +99,17 @@
 
   @query('#emailEditor', true) emailEditor!: GrEmailEditor;
 
-  @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
-
-  @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
-
-  @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
-
-  @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
-
-  @query('#allowBrowserNotifications')
-  allowBrowserNotifications?: HTMLInputElement;
-
-  @query('#allowSuggestCodeWhileCommenting')
-  allowSuggestCodeWhileCommenting?: HTMLInputElement;
-
-  @query('#disableKeyboardShortcuts')
-  disableKeyboardShortcuts!: HTMLInputElement;
-
-  @query('#disableTokenHighlighting')
-  disableTokenHighlighting!: HTMLInputElement;
-
-  @query('#relativeDateInChangeTable')
-  relativeDateInChangeTable!: HTMLInputElement;
-
-  @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
-
-  @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
-
-  @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
-
-  @query('#emailNotificationsSelect')
-  emailNotificationsSelect!: HTMLInputElement;
-
-  @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
-
-  @query('#defaultBaseForMergesSelect')
-  defaultBaseForMergesSelect!: HTMLInputElement;
-
-  @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
-
-  @query('#themeSelect') themeSelect!: HTMLInputElement;
-
   @state() prefs: PreferencesInput = {};
 
   @state() private accountInfoChanged = false;
 
   // private but used in test
-  @state() localPrefs: PreferencesInput = {};
-
-  // private but used in test
   @state() localChangeTableColumns: string[] = [];
 
   @state() private loading = true;
 
   @state() private changeTableChanged = false;
 
-  // private but used in test
-  @state() prefsChanged = false;
-
   @state() private diffPrefsChanged = false;
 
   @state() private watchedProjectsChanged = false;
@@ -199,10 +142,7 @@
 
   @state() isDeletingAccount = false;
 
-  @state() private docsBaseUrl = '';
-
-  @state()
-  suggestionsProvider?: SuggestionsProvider;
+  @state() accountState?: string;
 
   // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
@@ -218,10 +158,6 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
-  private readonly getConfigModel = resolve(this, configModelToken);
-
-  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
-
   constructor() {
     super();
     subscribe(
@@ -248,16 +184,9 @@
         }
         this.prefs = prefs;
         this.showNumber = !!prefs.legacycid_in_change_table;
-        this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this.prefsChanged = false;
         this.localChangeTableColumns = changeTablePrefs(prefs);
       }
     );
-    subscribe(
-      this,
-      () => this.getConfigModel().docsBaseUrl$,
-      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
-    );
   }
 
   // private, but used in tests
@@ -266,7 +195,7 @@
     const message = await this.restApiService.confirmEmail(this.emailToken);
     if (message) fireAlert(this, message);
     this.getViewModel().clearToken();
-    await this.emailEditor.loadData();
+    await this.getUserModel().loadEmails(true);
   }
 
   override connectedCallback() {
@@ -275,14 +204,15 @@
     // we need to manually calling scrollIntoView when hash changed
     document.addEventListener('location-change', this.handleLocationChange);
     fireTitleChange('Settings');
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        const suggestionsPlugins =
-          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
-        // We currently support results from only 1 provider.
-        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
-      });
+  }
+
+  private async getAccountState() {
+    const state = await this.restApiService.getAccountState();
+    if (state) {
+      this.accountState = JSON.stringify(state, null, 2);
+    } else {
+      this.accountState = 'ERROR: failed to get account state';
+    }
   }
 
   override firstUpdated() {
@@ -314,8 +244,6 @@
       })
     );
 
-    promises.push(this.emailEditor.loadData());
-
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
       this.loading = false;
 
@@ -336,6 +264,7 @@
       css`
         :host {
           color: var(--primary-text-color);
+          overflow: auto;
         }
         h2 {
           font-family: var(--header-font-family);
@@ -361,9 +290,19 @@
           margin-bottom: var(--spacing-l);
           margin-right: var(--spacing-l);
         }
-        .delete-account-button {
+        .account-button {
           margin-left: var(--spacing-l);
         }
+        .account-state-output {
+          width: 100vh;
+          max-width: calc(100% - var(--spacing-xl));
+          height: 50vh;
+          margin-bottom: var(--spacing-l);
+        }
+        .account-state-note {
+          width: 100vh;
+          max-width: calc(100% - var(--spacing-xl));
+        }
         .confirm-account-deletion-main ul {
           list-style: disc inside;
           margin-left: var(--spacing-l);
@@ -424,6 +363,9 @@
               @unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
                 this.accountInfoChanged = e.detail.value;
               }}
+              @account-detail-update=${() => {
+                fire(this, 'account-detail-update', {});
+              }}
             ></gr-account-info>
             <gr-button
               @click=${() => {
@@ -433,12 +375,19 @@
               >Save changes</gr-button
             >
             <gr-button
-              class="delete-account-button"
+              class="account-button"
               @click=${() => {
                 this.confirmDeleteAccount();
               }}
               >Delete Account</gr-button
             >
+            <gr-button
+              class="account-button"
+              @click=${() => {
+                this.dumpAccountState();
+              }}
+              >Dump Account State</gr-button
+            >
             <dialog id="confirm-account-deletion">
               <gr-dialog
                 @cancel=${() => this.deleteAccountConfirmationDialog?.close()}
@@ -458,33 +407,29 @@
                 </div>
               </gr-dialog>
             </dialog>
+            <dialog id="dump-account-state">
+              <gr-dialog
+                cancel-label=""
+                @confirm=${() =>
+                  this.dumpAccountStateConfirmationDialog?.close()}
+                confirm-label="OK"
+                confirm-on-enter=""
+              >
+                <div slot="header">Account State:</div>
+                <div slot="main">
+                  <textarea class="account-state-output" readonly>
+${this.accountState}</textarea
+                  >
+                  <p class="account-state-note">
+                    Note: The account state may contain sensitive data (e.g.
+                    deadnames). Share it with others only on a need to know
+                    basis (e.g. for debugging account or permission issues).
+                  </p>
+                </div>
+              </gr-dialog>
+            </dialog>
           </fieldset>
-          <h2
-            id="Preferences"
-            class=${this.computeHeaderClass(this.prefsChanged)}
-          >
-            Preferences
-          </h2>
-          <fieldset id="preferences">
-            ${this.renderTheme()} ${this.renderChangesPerPages()}
-            ${this.renderDateTimeFormat()} ${this.renderEmailNotification()}
-            ${this.renderEmailFormat()} ${this.renderBrowserNotifications()}
-            ${this.renderGenerateSuggestionWhenCommenting()}
-            ${this.renderDefaultBaseForMerges()}
-            ${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
-            ${this.renderShowSizeBarsInFileList()}
-            ${this.renderPublishCommentsOnPush()}
-            ${this.renderWorkInProgressByDefault()}
-            ${this.renderDisableKeyboardShortcuts()}
-            ${this.renderDisableTokenHighlighting()}
-            ${this.renderInsertSignedOff()}
-            <gr-button
-              id="savePrefs"
-              @click=${this.handleSavePreferences}
-              ?disabled=${!this.prefsChanged}
-              >Save changes</gr-button
-            >
-          </fieldset>
+          <gr-preferences id="preferences"></gr-preferences>
           <h2
             id="DiffPreferences"
             class=${this.computeHeaderClass(this.diffPrefsChanged)}
@@ -546,7 +491,6 @@
           </h2>
           <fieldset id="watchedProjects">
             <gr-watched-projects-editor
-              ?hasUnsavedChanges=${this.watchedProjectsChanged}
               @has-unsaved-changes-changed=${(
                 e: ValueChangedEvent<boolean>
               ) => {
@@ -572,7 +516,6 @@
           <fieldset id="email">
             <gr-email-editor
               id="emailEditor"
-              ?hasUnsavedChanges=${this.emailsChanged}
               @has-unsaved-changes-changed=${(
                 e: ValueChangedEvent<boolean>
               ) => {
@@ -580,8 +523,8 @@
               }}
             ></gr-email-editor>
             <gr-button
-              @click=${() => {
-                this.emailEditor.save();
+              @click=${async () => {
+                await this.emailEditor.save();
               }}
               ?disabled=${!this.emailsChanged}
               >Save changes</gr-button
@@ -701,435 +644,6 @@
     super.disconnectedCallback();
   }
 
-  private renderTheme() {
-    return html`
-      <section>
-        <label class="title" for="themeSelect">Theme</label>
-        <span class="value">
-          <gr-select
-            .bindValue=${this.localPrefs.theme ?? AppTheme.AUTO}
-            @change=${() => {
-              this.localPrefs.theme = this.themeSelect.value as AppTheme;
-              this.prefsChanged = true;
-            }}
-          >
-            <select id="themeSelect">
-              <option value="AUTO">Auto (based on OS prefs)</option>
-              <option value="LIGHT">Light</option>
-              <option value="DARK">Dark</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    `;
-  }
-
-  private renderChangesPerPages() {
-    return html`
-      <section>
-        <label class="title" for="changesPerPageSelect">Changes per page</label>
-        <span class="value">
-          <gr-select
-            .bindValue=${this.convertToString(this.localPrefs.changes_per_page)}
-            @change=${() => {
-              this.localPrefs.changes_per_page = Number(
-                this.changesPerPageSelect.value
-              ) as 10 | 25 | 50 | 100;
-              this.prefsChanged = true;
-            }}
-          >
-            <select id="changesPerPageSelect">
-              <option value="10">10 rows per page</option>
-              <option value="25">25 rows per page</option>
-              <option value="50">50 rows per page</option>
-              <option value="100">100 rows per page</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    `;
-  }
-
-  private renderDateTimeFormat() {
-    return html`
-      <section>
-        <label class="title" for="dateTimeFormatSelect">Date/time format</label>
-        <span class="value">
-          <gr-select
-            .bindValue=${this.convertToString(this.localPrefs.date_format)}
-            @change=${() => {
-              this.localPrefs.date_format = this.dateTimeFormatSelect
-                .value as DateFormat;
-              this.prefsChanged = true;
-            }}
-          >
-            <select id="dateTimeFormatSelect">
-              <option value="STD">Jun 3 ; Jun 3, 2016</option>
-              <option value="US">06/03 ; 06/03/16</option>
-              <option value="ISO">06-03 ; 2016-06-03</option>
-              <option value="EURO">3. Jun ; 03.06.2016</option>
-              <option value="UK">03/06 ; 03/06/2016</option>
-            </select>
-          </gr-select>
-          <gr-select
-            .bindValue=${this.convertToString(this.localPrefs.time_format)}
-            aria-label="Time Format"
-            @change=${() => {
-              this.localPrefs.time_format = this.timeFormatSelect
-                .value as TimeFormat;
-              this.prefsChanged = true;
-            }}
-          >
-            <select id="timeFormatSelect">
-              <option value="HHMM_12">4:10 PM</option>
-              <option value="HHMM_24">16:10</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    `;
-  }
-
-  private renderEmailNotification() {
-    return html`
-      <section>
-        <label class="title" for="emailNotificationsSelect"
-          >Email notifications</label
-        >
-        <span class="value">
-          <gr-select
-            .bindValue=${this.convertToString(this.localPrefs.email_strategy)}
-            @change=${() => {
-              this.localPrefs.email_strategy = this.emailNotificationsSelect
-                .value as EmailStrategy;
-              this.prefsChanged = true;
-            }}
-          >
-            <select id="emailNotificationsSelect">
-              <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-              <option value="ENABLED">Only comments left by others</option>
-              <option value="ATTENTION_SET_ONLY">
-                Only when I am in the attention set
-              </option>
-              <option value="DISABLED">None</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    `;
-  }
-
-  private renderEmailFormat() {
-    if (!this.localPrefs.email_format) return nothing;
-    return html`
-      <section>
-        <label class="title" for="emailFormatSelect">Email format</label>
-        <span class="value">
-          <gr-select
-            .bindValue=${this.convertToString(this.localPrefs.email_format)}
-            @change=${() => {
-              this.localPrefs.email_format = this.emailFormatSelect
-                .value as EmailFormat;
-              this.prefsChanged = true;
-            }}
-          >
-            <select id="emailFormatSelect">
-              <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-              <option value="PLAINTEXT">Plaintext only</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    `;
-  }
-
-  private renderBrowserNotifications() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
-      return nothing;
-    if (
-      !this.flagsService.isEnabled(
-        KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
-      ) &&
-      !areNotificationsEnabled(this.account)
-    )
-      return nothing;
-    return html`
-      <section id="allowBrowserNotificationsSection">
-        <div class="title">
-          <label for="allowBrowserNotifications"
-            >Allow browser notifications</label
-          >
-          <a
-            href=${getDocUrl(
-              this.docsBaseUrl,
-              'user-attention-set.html#_browser_notifications'
-            )}
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <gr-icon icon="help" title="read documentation"></gr-icon>
-          </a>
-        </div>
-        <span class="value">
-          <input
-            id="allowBrowserNotifications"
-            type="checkbox"
-            ?checked=${this.localPrefs.allow_browser_notifications}
-            @change=${() => {
-              this.localPrefs.allow_browser_notifications =
-                this.allowBrowserNotifications!.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderGenerateSuggestionWhenCommenting() {
-    if (
-      !this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
-      !this.suggestionsProvider
-    )
-      return nothing;
-    return html`
-      <section id="allowSuggestCodeWhileCommentingSection">
-        <div class="title">
-          <label for="allowSuggestCodeWhileCommenting"
-            >Allow generating suggestions while commenting</label
-          >
-          <a
-            href=${getDocUrl(
-              this.docsBaseUrl,
-              'user-suggest-edits.html#_generate_suggestion'
-            )}
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <gr-icon icon="help" title="read documentation"></gr-icon>
-          </a>
-        </div>
-        <span class="value">
-          <input
-            id="allowSuggestCodeWhileCommenting"
-            type="checkbox"
-            ?checked=${this.localPrefs.allow_suggest_code_while_commenting}
-            @change=${() => {
-              this.localPrefs.allow_suggest_code_while_commenting =
-                this.allowSuggestCodeWhileCommenting!.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderDefaultBaseForMerges() {
-    if (!this.localPrefs.default_base_for_merges) return nothing;
-    return nothing;
-    // TODO: Re-enable respecting the default_base_for_merges preference.
-    // See corresponding TODO in change-model.
-    // return html`
-    //   <section>
-    //     <span class="title">Default Base For Merges</span>
-    //     <span class="value">
-    //       <gr-select
-    //         .bindValue=${this.convertToString(
-    //           this.localPrefs.default_base_for_merges
-    //         )}
-    //         @change=${() => {
-    //           this.localPrefs.default_base_for_merges = this
-    //             .defaultBaseForMergesSelect.value as DefaultBase;
-    //           this.prefsChanged = true;
-    //         }}
-    //       >
-    //         <select id="defaultBaseForMergesSelect">
-    //           <option value="AUTO_MERGE">Auto Merge</option>
-    //           <option value="FIRST_PARENT">First Parent</option>
-    //         </select>
-    //       </gr-select>
-    //     </span>
-    //   </section>
-    // `;
-  }
-
-  private renderRelativeDateInChangeTable() {
-    return html`
-      <section>
-        <label class="title" for="relativeDateInChangeTable"
-          >Show Relative Dates In Changes Table</label
-        >
-        <span class="value">
-          <input
-            id="relativeDateInChangeTable"
-            type="checkbox"
-            ?checked=${this.localPrefs.relative_date_in_change_table}
-            @change=${() => {
-              this.localPrefs.relative_date_in_change_table =
-                this.relativeDateInChangeTable.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderDiffView() {
-    return html`
-      <section>
-        <span class="title">Diff view</span>
-        <span class="value">
-          <gr-select
-            .bindValue=${this.convertToString(this.localPrefs.diff_view)}
-            @change=${() => {
-              this.localPrefs.diff_view = this.diffViewSelect
-                .value as DiffViewMode;
-              this.prefsChanged = true;
-            }}
-          >
-            <select id="diffViewSelect">
-              <option value="SIDE_BY_SIDE">Side by side</option>
-              <option value="UNIFIED_DIFF">Unified diff</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    `;
-  }
-
-  private renderShowSizeBarsInFileList() {
-    return html`
-      <section>
-        <label for="showSizeBarsInFileList" class="title"
-          >Show size bars in file list</label
-        >
-        <span class="value">
-          <input
-            id="showSizeBarsInFileList"
-            type="checkbox"
-            ?checked=${this.localPrefs.size_bar_in_change_table}
-            @change=${() => {
-              this.localPrefs.size_bar_in_change_table =
-                this.showSizeBarsInFileList.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderPublishCommentsOnPush() {
-    return html`
-      <section>
-        <label for="publishCommentsOnPush" class="title"
-          >Publish comments on push</label
-        >
-        <span class="value">
-          <input
-            id="publishCommentsOnPush"
-            type="checkbox"
-            ?checked=${this.localPrefs.publish_comments_on_push}
-            @change=${() => {
-              this.localPrefs.publish_comments_on_push =
-                this.publishCommentsOnPush.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderWorkInProgressByDefault() {
-    return html`
-      <section>
-        <label for="workInProgressByDefault" class="title"
-          >Set new changes to "work in progress" by default</label
-        >
-        <span class="value">
-          <input
-            id="workInProgressByDefault"
-            type="checkbox"
-            ?checked=${this.localPrefs.work_in_progress_by_default}
-            @change=${() => {
-              this.localPrefs.work_in_progress_by_default =
-                this.workInProgressByDefault.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderDisableKeyboardShortcuts() {
-    return html`
-      <section>
-        <label for="disableKeyboardShortcuts" class="title"
-          >Disable all keyboard shortcuts</label
-        >
-        <span class="value">
-          <input
-            id="disableKeyboardShortcuts"
-            type="checkbox"
-            ?checked=${this.localPrefs.disable_keyboard_shortcuts}
-            @change=${() => {
-              this.localPrefs.disable_keyboard_shortcuts =
-                this.disableKeyboardShortcuts.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderDisableTokenHighlighting() {
-    return html`
-      <section>
-        <label for="disableTokenHighlighting" class="title"
-          >Disable token highlighting on hover</label
-        >
-        <span class="value">
-          <input
-            id="disableTokenHighlighting"
-            type="checkbox"
-            ?checked=${this.localPrefs.disable_token_highlighting}
-            @change=${() => {
-              this.localPrefs.disable_token_highlighting =
-                this.disableTokenHighlighting.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
-  private renderInsertSignedOff() {
-    return html`
-      <section>
-        <label for="insertSignedOff" class="title">
-          Insert Signed-off-by Footer For Inline Edit Changes
-        </label>
-        <span class="value">
-          <input
-            id="insertSignedOff"
-            type="checkbox"
-            ?checked=${this.localPrefs.signed_off_by}
-            @change=${() => {
-              this.localPrefs.signed_off_by = this.insertSignedOff.checked;
-              this.prefsChanged = true;
-            }}
-          />
-        </span>
-      </section>
-    `;
-  }
-
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
@@ -1143,33 +657,15 @@
   };
 
   reloadAccountDetail() {
-    Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
-  }
-
-  private copyPrefs(direction: CopyPrefsDirection) {
-    if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
-      this.prefs = {
-        ...this.localPrefs,
-      };
-    } else {
-      this.localPrefs = {
-        ...this.prefs,
-      };
-    }
+    Promise.all([this.accountInfo.loadData()]);
   }
 
   // private but used in test
-  handleSavePreferences() {
-    return this.getUserModel().updatePreferences(this.localPrefs);
-  }
-
-  // private but used in test
-  handleSaveChangeTable() {
+  async handleSaveChangeTable() {
     this.prefs.change_table = this.localChangeTableColumns;
     this.prefs.legacycid_in_change_table = this.showNumber;
-    return this.restApiService.savePreferences(this.prefs).then(() => {
-      this.changeTableChanged = false;
-    });
+    await this.getUserModel().updatePreferences(this.prefs);
+    this.changeTableChanged = false;
   }
 
   private computeHeaderClass(changed?: boolean) {
@@ -1199,7 +695,7 @@
     if (!this.isNewEmailValid(this.newEmail)) return;
 
     this.addingEmail = true;
-    this.restApiService.addAccountEmail(this.newEmail).then(response => {
+    this.restApiService.addAccountEmail(this.newEmail).then(async response => {
       this.addingEmail = false;
 
       // If it was unsuccessful.
@@ -1209,6 +705,8 @@
 
       this.lastSentVerificationEmail = this.newEmail;
       this.newEmail = '';
+
+      await this.getUserModel().loadEmails(true);
     });
   }
 
@@ -1224,6 +722,11 @@
     this.getNavigation().setUrl(rootUrl());
   }
 
+  private async dumpAccountState() {
+    await this.getAccountState();
+    this.dumpAccountStateConfirmationDialog?.showModal();
+  }
+
   // private but used in test
   showHttpAuth() {
     if (this.serverConfig?.auth?.git_basic_auth_policy) {
@@ -1234,25 +737,6 @@
 
     return false;
   }
-
-  /**
-   * bind-value has type string so we have to convert anything inputed
-   * to string.
-   *
-   * This is so typescript template checker doesn't fail.
-   */
-  private convertToString(
-    key?:
-      | DateFormat
-      | DefaultBase
-      | DiffViewMode
-      | EmailFormat
-      | EmailStrategy
-      | TimeFormat
-      | number
-  ) {
-    return key !== undefined ? String(key) : '';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index f9b1738..ec8a0e2 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -3,16 +3,11 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
-import {
-  queryAll,
-  queryAndAssert,
-  stubFlags,
-  stubRestApi,
-  waitEventLoop,
-} from '../../../test/test-utils';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {
   AuthInfo,
   AccountDetailInfo,
@@ -22,7 +17,6 @@
   TopMenuItemInfo,
 } from '../../../types/common';
 import {
-  createDefaultPreferences,
   DateFormat,
   DefaultBase,
   DiffViewMode,
@@ -36,7 +30,6 @@
   createPreferences,
   createServerInfo,
 } from '../../../test/test-data-generators';
-import {GrSelect} from '../../shared/gr-select/gr-select';
 import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-settings-view tests', () => {
@@ -45,36 +38,6 @@
   let preferences: PreferencesInfo;
   let config: ServerInfo;
 
-  function valueOf(title: string, id: string) {
-    const sections = queryAll(element, `#${id} section`);
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl?.textContent?.trim() === title) {
-        const el = sections[i].querySelector('.value');
-        if (el) return el;
-      }
-    }
-    assert.fail(`element with title ${title} not found`);
-  }
-
-  // Because deepEqual isn't behaving in Safari.
-  function assertMenusEqual(
-    actual?: TopMenuItemInfo[],
-    expected?: TopMenuItemInfo[]
-  ) {
-    if (actual === undefined) {
-      assert.fail("assertMenusEqual 'actual' param is undefined");
-    } else if (expected === undefined) {
-      assert.fail("assertMenusEqual 'expected' param is undefined");
-    }
-    assert.equal(actual.length, expected.length);
-    for (let i = 0; i < actual.length; i++) {
-      assert.equal(actual[i].name, expected[i].name);
-      assert.equal(actual[i].url, expected[i].url);
-    }
-  }
-
   function stubAddAccountEmail(statusCode: number) {
     return stubRestApi('addAccountEmail').callsFake(() =>
       Promise.resolve({status: statusCode} as Response)
@@ -163,202 +126,70 @@
             </gr-button>
             <gr-button
               aria-disabled="false"
-              class="delete-account-button"
+              class="account-button"
               role="button"
               tabindex="0"
             >
               Delete Account
             </gr-button>
-            <dialog id="confirm-account-deletion">
-            <gr-dialog role="dialog">
-              <div
-                class="confirm-account-deletion-header"
-                slot="header"
-              >
-              Are you sure you wish to delete your account?
-              </div>
-              <div
-                class="confirm-account-deletion-main"
-                slot="main"
-              >
-                <ul>
-                  <li>
-                    Deleting your account is not reversible.
-                  </li>
-                  <li>
-                    Deleting your account will not delete your changes.
-                  </li>
-                </ul>
-              </div>
-            </gr-dialog>
-          </dialog>
-          </fieldset>
-          <h2 id="Preferences">Preferences</h2>
-          <fieldset id="preferences">
-            <section>
-              <label class="title" for="themeSelect">
-                Theme
-              </label>
-              <span class="value">
-                <gr-select>
-                  <select id="themeSelect">
-                    <option value="AUTO">Auto (based on OS prefs)</option>
-                    <option value="LIGHT">Light</option>
-                    <option value="DARK">Dark</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="changesPerPageSelect">
-                Changes per page
-              </label>
-              <span class="value">
-                <gr-select>
-                  <select id="changesPerPageSelect">
-                    <option value="10">10 rows per page</option>
-                    <option value="25">25 rows per page</option>
-                    <option value="50">50 rows per page</option>
-                    <option value="100">100 rows per page</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="dateTimeFormatSelect">
-                Date/time format
-              </label>
-              <span class="value">
-                <gr-select>
-                  <select id="dateTimeFormatSelect">
-                    <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                    <option value="US">06/03 ; 06/03/16</option>
-                    <option value="ISO">06-03 ; 2016-06-03</option>
-                    <option value="EURO">3. Jun ; 03.06.2016</option>
-                    <option value="UK">03/06 ; 03/06/2016</option>
-                  </select>
-                </gr-select>
-                <gr-select aria-label="Time Format">
-                  <select id="timeFormatSelect">
-                    <option value="HHMM_12">4:10 PM</option>
-                    <option value="HHMM_24">16:10</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="emailNotificationsSelect">
-                Email notifications
-              </label>
-              <span class="value">
-                <gr-select>
-                  <select id="emailNotificationsSelect">
-                    <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                    <option value="ENABLED">
-                      Only comments left by others
-                    </option>
-                    <option value="ATTENTION_SET_ONLY">
-                      Only when I am in the attention set
-                    </option>
-                    <option value="DISABLED">None</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="emailFormatSelect">
-                Email format
-              </label>
-              <span class="value">
-                <gr-select>
-                  <select id="emailFormatSelect">
-                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                    <option value="PLAINTEXT">Plaintext only</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="relativeDateInChangeTable">
-                Show Relative Dates In Changes Table
-              </label>
-              <span class="value">
-                <input id="relativeDateInChangeTable" type="checkbox" />
-              </span>
-            </section>
-            <section>
-              <span class="title"> Diff view </span>
-              <span class="value">
-                <gr-select>
-                  <select id="diffViewSelect">
-                    <option value="SIDE_BY_SIDE">Side by side</option>
-                    <option value="UNIFIED_DIFF">Unified diff</option>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <label class="title" for="showSizeBarsInFileList">
-                Show size bars in file list
-              </label>
-              <span class="value">
-                <input
-                  checked=""
-                  id="showSizeBarsInFileList"
-                  type="checkbox"
-                />
-              </span>
-            </section>
-            <section>
-              <label class="title" for="publishCommentsOnPush">
-                Publish comments on push
-              </label>
-              <span class="value">
-                <input id="publishCommentsOnPush" type="checkbox" />
-              </span>
-            </section>
-            <section>
-              <label class="title" for="workInProgressByDefault">
-                Set new changes to "work in progress" by default
-              </label>
-              <span class="value">
-                <input id="workInProgressByDefault" type="checkbox" />
-              </span>
-            </section>
-            <section>
-              <label class="title" for="disableKeyboardShortcuts">
-                Disable all keyboard shortcuts
-              </label>
-              <span class="value">
-                <input id="disableKeyboardShortcuts" type="checkbox" />
-              </span>
-            </section>
-            <section>
-              <label class="title" for="disableTokenHighlighting">
-                Disable token highlighting on hover
-              </label>
-              <span class="value">
-                <input id="disableTokenHighlighting" type="checkbox" />
-              </span>
-            </section>
-            <section>
-              <label class="title" for="insertSignedOff">
-                Insert Signed-off-by Footer For Inline Edit Changes
-              </label>
-              <span class="value">
-                <input id="insertSignedOff" type="checkbox" />
-              </span>
-            </section>
             <gr-button
-              aria-disabled="true"
-              disabled=""
-              id="savePrefs"
+              aria-disabled="false"
+              class="account-button"
               role="button"
-              tabindex="-1"
+              tabindex="0"
             >
-              Save changes
+              Dump Account State
             </gr-button>
+            <dialog id="confirm-account-deletion">
+              <gr-dialog role="dialog">
+                <div
+                  class="confirm-account-deletion-header"
+                  slot="header"
+                >
+                Are you sure you wish to delete your account?
+                </div>
+                <div
+                  class="confirm-account-deletion-main"
+                  slot="main"
+                >
+                  <ul>
+                    <li>
+                      Deleting your account is not reversible.
+                    </li>
+                    <li>
+                      Deleting your account will not delete your changes.
+                    </li>
+                  </ul>
+                </div>
+              </gr-dialog>
+            </dialog>
+            <dialog id="dump-account-state">
+              <gr-dialog
+                cancel-label=""
+                confirm-label="OK"
+                confirm-on-enter=""
+                role="dialog"
+              >
+                <div slot="header">
+                  Account State:
+                </div>
+                <div slot="main">
+                  <textarea
+                    class="account-state-output"
+                    readonly=""
+                  >
+                    <!----><!---->
+                  </textarea>
+                  <p class="account-state-note">
+                    Note: The account state may contain sensitive data (e.g.
+                    deadnames). Share it with others only on a need to know
+                    basis (e.g. for debugging account or permission issues).
+                  </p>
+                </div>
+              </gr-dialog>
+            </dialog>
           </fieldset>
+          <gr-preferences id="preferences"> </gr-preferences>
           <h2 id="DiffPreferences">Diff Preferences</h2>
           <fieldset id="diffPreferences">
             <gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
@@ -456,33 +287,6 @@
     );
   });
 
-  test('allow browser notifications', async () => {
-    stubFlags('isEnabled').returns(true);
-    element = await fixture(html`<gr-settings-view></gr-settings-view>`);
-    element.account = createAccountDetailWithId();
-    await element.updateComplete;
-    assert.dom.equal(
-      queryAndAssert(element, '#allowBrowserNotificationsSection'),
-      /* HTML */ `<section id="allowBrowserNotificationsSection">
-        <div class="title">
-          <label for="allowBrowserNotifications">
-            Allow browser notifications
-          </label>
-          <a
-            href="/Documentation/user-attention-set.html#_browser_notifications"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <gr-icon icon="help" title="read documentation"> </gr-icon>
-          </a>
-        </div>
-        <span class="value">
-          <input checked="" id="allowBrowserNotifications" type="checkbox" />
-        </span>
-      </section>`
-    );
-  });
-
   test('calls the title-change event', async () => {
     const titleChangedStub = sinon.stub();
     const newElement = document.createElement('gr-settings-view');
@@ -497,167 +301,6 @@
     assert.equal(titleChangedStub.getCall(0).args[0].detail.title, 'Settings');
   });
 
-  test('user preferences', async () => {
-    // Rendered with the expected preferences selected.
-    assert.equal(
-      Number(
-        (
-          valueOf('Changes per page', 'preferences')!
-            .firstElementChild as GrSelect
-        ).bindValue
-      ),
-      preferences.changes_per_page
-    );
-    assert.equal(
-      (valueOf('Theme', 'preferences').firstElementChild as GrSelect).bindValue,
-      preferences.theme
-    );
-    assert.equal(
-      (
-        valueOf('Date/time format', 'preferences')!
-          .firstElementChild as GrSelect
-      ).bindValue,
-      preferences.date_format
-    );
-    assert.equal(
-      (valueOf('Date/time format', 'preferences')!.lastElementChild as GrSelect)
-        .bindValue,
-      preferences.time_format
-    );
-    assert.equal(
-      (
-        valueOf('Email notifications', 'preferences')!
-          .firstElementChild as GrSelect
-      ).bindValue,
-      preferences.email_strategy
-    );
-    assert.equal(
-      (valueOf('Email format', 'preferences')!.firstElementChild as GrSelect)
-        .bindValue,
-      preferences.email_format
-    );
-    assert.equal(
-      (
-        valueOf('Show Relative Dates In Changes Table', 'preferences')!
-          .firstElementChild as HTMLInputElement
-      ).checked,
-      false
-    );
-    assert.equal(
-      (valueOf('Diff view', 'preferences')!.firstElementChild as GrSelect)
-        .bindValue,
-      preferences.diff_view
-    );
-    assert.equal(
-      (
-        valueOf('Show size bars in file list', 'preferences')!
-          .firstElementChild as HTMLInputElement
-      ).checked,
-      true
-    );
-    assert.equal(
-      (
-        valueOf('Publish comments on push', 'preferences')!
-          .firstElementChild as HTMLInputElement
-      ).checked,
-      false
-    );
-    assert.equal(
-      (
-        valueOf(
-          'Set new changes to "work in progress" by default',
-          'preferences'
-        )!.firstElementChild as HTMLInputElement
-      ).checked,
-      false
-    );
-    assert.equal(
-      (
-        valueOf('Disable token highlighting on hover', 'preferences')!
-          .firstElementChild as HTMLInputElement
-      ).checked,
-      false
-    );
-    assert.equal(
-      (
-        valueOf(
-          'Insert Signed-off-by Footer For Inline Edit Changes',
-          'preferences'
-        )!.firstElementChild as HTMLInputElement
-      ).checked,
-      false
-    );
-
-    assert.isFalse(element.prefsChanged);
-
-    const themeSelect = valueOf('Theme', 'preferences')
-      .firstElementChild as GrSelect;
-    themeSelect.bindValue = 'DARK';
-
-    themeSelect.dispatchEvent(
-      new CustomEvent('change', {
-        composed: true,
-        bubbles: true,
-      })
-    );
-
-    const publishOnPush = valueOf('Publish comments on push', 'preferences')!
-      .firstElementChild! as HTMLSpanElement;
-
-    publishOnPush.click();
-
-    assert.isTrue(element.prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assertMenusEqual(prefs.my, preferences.my);
-      assert.equal(prefs.publish_comments_on_push, true);
-      assert.equal(prefs.theme, AppTheme.DARK);
-      return Promise.resolve(createDefaultPreferences());
-    });
-
-    // Save the change.
-    await element.handleSavePreferences();
-    assert.isFalse(element.prefsChanged);
-  });
-
-  test('publish comments on push', async () => {
-    const publishCommentsOnPush = valueOf(
-      'Publish comments on push',
-      'preferences'
-    )!.firstElementChild! as HTMLSpanElement;
-    publishCommentsOnPush.click();
-
-    assert.isTrue(element.prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assert.equal(prefs.publish_comments_on_push, true);
-      return Promise.resolve(createDefaultPreferences());
-    });
-
-    // Save the change.
-    await element.handleSavePreferences();
-    assert.isFalse(element.prefsChanged);
-  });
-
-  test('set new changes work-in-progress', async () => {
-    const newChangesWorkInProgress = valueOf(
-      'Set new changes to "work in progress" by default',
-      'preferences'
-    )!.firstElementChild! as HTMLSpanElement;
-    newChangesWorkInProgress.click();
-
-    assert.isTrue(element.prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assert.equal(prefs.work_in_progress_by_default, true);
-      return Promise.resolve(createDefaultPreferences());
-    });
-
-    // Save the change.
-    await element.handleSavePreferences();
-    assert.isFalse(element.prefsChanged);
-  });
-
   test('add email validation', async () => {
     assert.isFalse(element.isNewEmailValid('invalid email'));
     assert.isTrue(element.isNewEmailValid('vaguely@valid.email'));
@@ -722,12 +365,6 @@
     assert.isNotOk(element.lastSentVerificationEmail);
   });
 
-  test('emails are loaded without emailToken', () => {
-    const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
-    element.firstUpdated();
-    assert.isTrue(emailEditorLoadDataStub.calledOnce);
-  });
-
   test('handleSaveChangeTable', () => {
     let newColumns = ['Owner', 'Project', 'Branch'];
     element.localChangeTableColumns = newColumns.slice(0);
@@ -778,10 +415,8 @@
       value: string | PromiseLike<string | null> | null
     ) => void;
     let confirmEmailStub: sinon.SinonStub;
-    let emailEditorLoadDataStub: sinon.SinonStub;
 
     setup(() => {
-      emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
       confirmEmailStub = stubRestApi('confirmEmail').returns(
         new Promise(resolve => {
           resolveConfirm = resolve;
@@ -797,16 +432,6 @@
       assert.isTrue(confirmEmailStub.calledWith('foo'));
     });
 
-    test('emails are not loaded initially', () => {
-      assert.isFalse(emailEditorLoadDataStub.called);
-    });
-
-    test('user emails are loaded after email confirmed', async () => {
-      resolveConfirm('bar');
-      await element._testOnly_loadingPromise;
-      assert.isTrue(emailEditorLoadDataStub.calledOnce);
-    });
-
     test('show-alert is fired when email is confirmed', async () => {
       const dispatchEventSpy = sinon.spy(element, 'dispatchEvent');
       resolveConfirm('bar');
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
index 9528fb2..fddb603 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-ssh-editor';
 import {
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index f0f0f2f..583798c 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -6,7 +6,7 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {
   AutocompleteQuery,
   GrAutocomplete,
@@ -22,6 +22,7 @@
 import {fire} from '../../../utils/event-util';
 import {PropertiesOfType} from '../../../utils/type-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {notDeepEqual} from '../../../utils/deep-util';
 
 type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
 
@@ -43,13 +44,13 @@
   @query('#newProject')
   newProject?: GrAutocomplete;
 
-  @property({type: Boolean})
-  hasUnsavedChanges = false;
+  @state()
+  originalProjects?: ProjectWatchInfo[];
 
-  @property({type: Array})
+  @state()
   projects?: ProjectWatchInfo[];
 
-  @property({type: Array})
+  @state()
   projectsToRemove: ProjectWatchInfo[] = [];
 
   private readonly query: AutocompleteQuery = input =>
@@ -163,7 +164,8 @@
 
   loadData() {
     return this.restApiService.getWatchedProjects().then(projs => {
-      this.projects = projs;
+      this.originalProjects = projs;
+      this.projects = projs ? [...projs] : [];
     });
   }
 
@@ -186,9 +188,10 @@
         }
       })
       .then(projects => {
-        this.projects = projects;
+        this.originalProjects = projects;
+        this.projects = projects ? [...projects] : [];
         this.projectsToRemove = [];
-        this.setHasUnsavedChanges(false);
+        this.setHasUnsavedChanges();
       });
   }
 
@@ -206,13 +209,16 @@
   }
 
   private handleRemoveProject(project: ProjectWatchInfo) {
-    if (!this.projects) return;
+    if (!this.projects || !this.originalProjects) return;
     const index = this.projects.indexOf(project);
     if (index < 0) return;
     this.projects.splice(index, 1);
-    this.projectsToRemove.push(project);
+    // Don't add project to projectsToRemove if it wasn't in
+    // originalProjects.
+    if (this.originalProjects.includes(project))
+      this.projectsToRemove.push(project);
     this.requestUpdate();
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   // private but used in tests.
@@ -288,7 +294,7 @@
 
     this.newProject.clear();
     this.newFilter.value = '';
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   private handleCheckboxChange(
@@ -300,7 +306,7 @@
     const checked = el.checked;
     project[key] = !!checked;
     this.requestUpdate();
-    this.setHasUnsavedChanges(true);
+    this.setHasUnsavedChanges();
   }
 
   private handleNotifCellClick(e: Event) {
@@ -311,9 +317,11 @@
     }
   }
 
-  private setHasUnsavedChanges(value: boolean) {
-    this.hasUnsavedChanges = value;
-    fire(this, 'has-unsaved-changes-changed', {value});
+  private setHasUnsavedChanges() {
+    const hasUnsavedChanges =
+      notDeepEqual(this.originalProjects, this.projects) ||
+      this.projectsToRemove.length > 0;
+    fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
   }
 
   isFilterDefined(filter: string | null | undefined) {
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index c608656..e771a9d 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-watched-projects-editor';
 import {GrWatchedProjectsEditor} from './gr-watched-projects-editor';
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
index 552e321..e8d7dae 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-account-entry';
 import {GrAccountEntry} from './gr-account-entry';
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 4c73fcf..7b2d3b0 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -10,9 +10,13 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import {getAppContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {isSelf, isServiceUser} from '../../../utils/account-util';
+import {
+  isDetailedAccount,
+  isSelf,
+  isServiceUser,
+} from '../../../utils/account-util';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
-import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {LitElement, css, html, TemplateResult} from 'lit';
@@ -21,17 +25,17 @@
 import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createSearchUrl} from '../../../models/views/search';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
 import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends LitElement {
   @property({type: Object})
   account?: AccountInfo;
 
-  @property({type: Object})
-  _selfAccount?: AccountInfo;
-
   /**
    * Optional ChangeInfo object, typically comes from the change page or
    * from a row in a list of search results. This is needed for some change
@@ -67,9 +71,6 @@
   @property({type: Boolean})
   hideAvatar = false;
 
-  @state()
-  _config?: ServerInfo;
-
   @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
@@ -94,12 +95,24 @@
   @property({type: Boolean, reflect: true})
   avatarShown = false;
 
+  // Private but used in tests.
+  @state()
+  selfAccount?: AccountInfo;
+
+  // Private but used in tests.
+  @state()
+  config?: ServerInfo;
+
   readonly reporting = getAppContext().reportingService;
 
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getAccountsModel = resolve(this, accountsModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
   static override get styles() {
     return [
       css`
@@ -189,25 +202,40 @@
     ];
   }
 
-  override async updated() {
-    assertIsDefined(this.account, 'account');
+  override updated() {
+    this.computeDetailedAccount();
+  }
+
+  private async computeDetailedAccount() {
+    if (!this.account) return;
+    // If this.account is already a detailed object, then there is no need to fill it.
+    if (isDetailedAccount(this.account)) return;
     const account = await this.getAccountsModel().fillDetails(this.account);
-    // AccountInfo returned by fillDetails has the email property set
-    // to the primary email of the account. This poses a problem in
-    // cases where a secondary email is used as the committer or author
-    // email. Therefore, only fill in the missing details to avoid
-    // displaying incorrect author or committer email.
-    if (account) this.account = Object.assign(account, this.account);
+    if (
+      account &&
+      // If we were not able to get a detailed object, then there is no point in updating the
+      // account.
+      isDetailedAccount(account) &&
+      account !== this.account &&
+      (!this.account._account_id ||
+        account._account_id === this.account._account_id)
+    ) {
+      // AccountInfo returned by fillDetails has the email property set
+      // to the primary email of the account. This poses a problem in
+      // cases where a secondary email is used as the committer or author
+      // email. Therefore, only fill in the *missing* properties.
+      this.account = {...account, ...this.account};
+    }
   }
 
   override render() {
-    const {account, change, highlightAttention, forceAttention, _config} = this;
+    const {account, change, highlightAttention, forceAttention, config} = this;
     if (!account) return;
     this.attentionIconShown =
       forceAttention ||
       this.hasUnforcedAttention(highlightAttention, account, change);
     this.deselected = !this.selected;
-    const hasAvatars = !!_config?.plugin?.has_avatars;
+    const hasAvatars = !!config?.plugin?.has_avatars;
     this.avatarShown = !this.hideAvatar && hasAvatars;
 
     return html`
@@ -227,7 +255,7 @@
                 account,
                 change,
                 false,
-                this._selfAccount
+                this.selfAccount
               )}
               title=${this.computeAttentionIconTitle(
                 highlightAttention,
@@ -235,7 +263,7 @@
                 change,
                 forceAttention,
                 this.selected,
-                this._selfAccount
+                this.selfAccount
               )}
             >
               <gr-button
@@ -248,7 +276,7 @@
                   account,
                   change,
                   this.selected,
-                  this._selfAccount
+                  this.selfAccount
                 )}
               >
                 <div>
@@ -280,7 +308,7 @@
               class="name"
               part="gr-account-label-text"
             >
-              ${this.computeName(account, this.firstName, this._config)}
+              ${this.computeName(account, this.firstName, this.config)}
             </span>
             ${this.renderAccountStatusPlugins()}
           </span>
@@ -291,12 +319,16 @@
 
   constructor() {
     super();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      x => (this.config = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.selfAccount = x)
+    );
     this.addEventListener('attention-set-updated', () => {
       // For re-evaluation of everything that depends on 'change'.
       if (this.change) this.change = {...this.change};
@@ -375,7 +407,7 @@
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const reason = getRemovedByIconClickReason(this._selfAccount, this._config);
+    const reason = getRemovedByIconClickReason(this.selfAccount, this.config);
     if (this.change.attention_set)
       delete this.change.attention_set[this.account._account_id];
     // For re-evaluation of everything that depends on 'change'.
@@ -401,7 +433,7 @@
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = this._selfAccount?._account_id || -1;
+    const selfId = this.selfAccount?._account_id || -1;
     const reviewers =
       this.change && this.change.reviewers && this.change.reviewers.REVIEWER
         ? [...this.change.reviewers.REVIEWER]
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index 71f8391..8b49cdd 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -154,11 +154,11 @@
   suite('attention set', () => {
     setup(async () => {
       element.highlightAttention = true;
-      element._config = {
+      element.config = {
         ...createServerInfo(),
         user: {anonymous_coward_name: 'Anonymous Coward'},
       };
-      element._selfAccount = kermit;
+      element.selfAccount = kermit;
       element.account = {
         ...createAccountDetailWithIdNameAndEmail(42),
         name: 'ernie',
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index eaf8974..887ef96 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-account-list';
 import {GrAccountList} from './gr-account-list';
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
index 6908e95..1fe860f 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-alert';
 import {GrAlert} from './gr-alert';
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 10ba5d0..b9063ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-autocomplete-dropdown';
 import {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index 0cef331..bf3ba66 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-autocomplete';
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
index 863ee90..b445f75 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -17,7 +17,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
 import {isDefined} from '../../../types/types';
 import {when} from 'lit/directives/when.js';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index 145e39d..f695ffa 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-button';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 0b03581..8787d3c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -70,7 +70,11 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {whenRendered} from '../../../utils/dom-util';
-import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  createChangeUrl,
+  createDiffUrl,
+} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 import {noAwait, waitUntil} from '../../../utils/async-util';
@@ -248,6 +252,8 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -706,12 +712,15 @@
   }
 
   private getDiffUrlForPath() {
-    if (!this.changeNum || !this.repoName || !this.thread?.path) {
+    if (
+      !this.changeNum ||
+      !this.repoName ||
+      !this.thread?.path ||
+      !this.thread?.patchNum
+    ) {
       return undefined;
     }
-    return createDiffUrl({
-      changeNum: this.changeNum,
-      repo: this.repoName,
+    return this.getViewModel().diffUrl({
       patchNum: this.thread.patchNum,
       diffView: {path: this.thread.path},
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 571a322..fb44c56 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-comment-thread';
 import {sortComments} from '../../../utils/comment-util';
@@ -38,6 +39,11 @@
   commentsModelToken,
 } from '../../../models/comments/comments-model';
 import {testResolver} from '../../../test/common-test-setup';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 const c1: CommentInfo = {
   author: {name: 'Kermit'},
@@ -80,6 +86,12 @@
   let element: GrCommentThread;
 
   setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 1 as NumericChangeId,
+      repo: 'test-repo-name' as RepoName,
+    });
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
     element.changeNum = 1 as NumericChangeId;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index bc9bd12..4c3aeaf 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -10,16 +10,17 @@
 import '../gr-dialog/gr-dialog';
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icon/gr-icon';
-import '../gr-textarea/gr-textarea';
+import '../gr-suggestion-textarea/gr-suggestion-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
 import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import '../gr-fix-suggestions/gr-fix-suggestions';
 import {getAppContext} from '../../../services/app-context';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {provide, resolve} from '../../../models/dependency';
-import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
 import {
   AccountDetailInfo,
   DraftInfo,
@@ -78,20 +79,35 @@
   commentModelToken,
 } from '../gr-comment-model/gr-comment-model';
 import {formStyles} from '../../../styles/form-styles';
-import {Interaction} from '../../../constants/reporting';
-import {Suggestion, SuggestionsProvider} from '../../../api/suggestions';
+import {Interaction, Timing} from '../../../constants/reporting';
+import {
+  AutocompleteCommentResponse,
+  SuggestionsProvider,
+} from '../../../api/suggestions';
 import {when} from 'lit/directives/when.js';
 import {getDocUrl} from '../../../utils/url-util';
 import {configModelToken} from '../../../models/config/config-model';
 import {getFileExtension} from '../../../utils/file-util';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {deepEqual} from '../../../utils/deep-util';
+import {
+  GrSuggestionDiffPreview,
+  PreviewLoadedDetail,
+} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {waitUntil} from '../../../utils/async-util';
+import {
+  AutocompleteCache,
+  AutocompletionContext,
+} from '../../../utils/autocomplete-cache';
+import {HintAppliedEventDetail, HintShownEventDetail} from '../../../api/embed';
+import {levenshteinDistance} from '../../../utils/string-util';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
-export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 1500;
+export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 500;
+export const AUTOCOMPLETE_DEBOUNCE_DELAY_MS = 200;
 export const ENABLE_GENERATE_SUGGESTION_STORAGE_KEY =
-  'enableGenerateSuggestionStorageKey';
+  'enableGenerateSuggestionStorageKeyForCommentWithId-';
 
 declare global {
   interface HTMLElementEventMap {
@@ -140,7 +156,7 @@
    */
 
   @query('#editTextarea')
-  textarea?: GrTextarea;
+  textarea?: GrSuggestionTextarea;
 
   @query('#container')
   container?: HTMLElement;
@@ -154,6 +170,9 @@
   @query('#confirmDeleteCommentDialog')
   confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
 
+  @query('#suggestionDiffPreview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
   @property({type: Object})
   comment?: Comment;
 
@@ -213,6 +232,19 @@
   @state()
   messageText = '';
 
+  /**
+   * An hint for autocompleting the comment message from plugin suggestion
+   * providers.
+   */
+  @state() autocompleteHint?: AutocompletionContext;
+
+  private autocompleteAcceptedHints: string[] = [];
+
+  /** Based on user preferences. */
+  @state() autocompleteEnabled = true;
+
+  readonly autocompleteCache = new AutocompleteCache();
+
   /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
   @state()
   unresolved = true;
@@ -221,16 +253,20 @@
   generateSuggestion = true;
 
   @state()
-  generatedSuggestion?: Suggestion;
-
-  @state()
   generatedFixSuggestion: FixSuggestionInfo | undefined =
     this.comment?.fix_suggestions?.[0];
 
   @state()
+  previewedGeneratedFixSuggestion: FixSuggestionInfo | undefined =
+    this.comment?.fix_suggestions?.[0];
+
+  @state()
   generatedSuggestionId?: string;
 
   @state()
+  addedGeneratedSuggestion?: string;
+
+  @state()
   suggestionsProvider?: SuggestionsProvider;
 
   @state()
@@ -291,6 +327,12 @@
   private generateSuggestionTrigger$ = new Subject();
 
   /**
+   * This is triggered when the user types into the editing textarea. We then
+   * debounce it and call autocompleteComment().
+   */
+  private autocompleteTrigger$ = new Subject();
+
+  /**
    * Set to the content of DraftInfo when entering editing mode.
    * Only used for "Cancel".
    */
@@ -313,8 +355,16 @@
     for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
       this.shortcuts.addLocal(
         {key: Key.ENTER, modifiers: [modifier]},
-        () => {
+        e => {
           this.save();
+          // We don't stop propagation for patchset comment
+          // (this.permanentEditingMode = true), but we stop it for normal
+          // comments. This prevents accidentally sending a reply when
+          // editing/saving them in the reply dialog.
+          if (!this.permanentEditingMode) {
+            e.preventDefault();
+            e.stopPropagation();
+          }
         },
         {preventDefault: false}
       );
@@ -332,9 +382,6 @@
     this.addEventListener('open-user-suggest-preview', e => {
       this.handleShowFix(e.detail.code);
     });
-    this.addEventListener('add-generated-suggestion', e => {
-      this.handleAddGeneratedSuggestion(e.detail.code);
-    });
     this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
@@ -375,53 +422,58 @@
       () => this.getConfigModel().docsBaseUrl$,
       docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
     );
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
-      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
-    ) {
-      subscribe(
-        this,
-        () =>
-          this.generateSuggestionTrigger$.pipe(
-            debounceTime(GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS)
-          ),
-        () => {
-          this.generateSuggestEdit();
+    subscribe(
+      this,
+      () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
+      // We currently support results from only 1 provider.
+      suggestionsPlugins =>
+        (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
+    );
+    subscribe(
+      this,
+      () =>
+        this.autocompleteTrigger$.pipe(
+          debounceTime(AUTOCOMPLETE_DEBOUNCE_DELAY_MS)
+        ),
+      () => {
+        this.autocompleteComment();
+      }
+    );
+    subscribe(
+      this,
+      () =>
+        this.generateSuggestionTrigger$.pipe(
+          debounceTime(GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS)
+        ),
+      () => {
+        this.generateSuggestEdit();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        this.autocompleteEnabled = !!prefs.allow_autocompleting_comments;
+        if (
+          this.generateSuggestion !==
+          !!prefs.allow_suggest_code_while_commenting
+        ) {
+          this.generateSuggestion = !!prefs.allow_suggest_code_while_commenting;
         }
-      );
-      subscribe(
-        this,
-        () => this.getUserModel().preferences$,
-        prefs => {
-          if (
-            this.generateSuggestion !==
-            !!prefs.allow_suggest_code_while_commenting
-          ) {
-            this.generateSuggestion =
-              !!prefs.allow_suggest_code_while_commenting;
-          }
-        }
-      );
-    }
+      }
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        const suggestionsPlugins =
-          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
-        // We currently support results from only 1 provider.
-        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
-      });
-
-    const generateSuggestionStoredContent =
-      this.getStorage().getEditableContentItem(
-        ENABLE_GENERATE_SUGGESTION_STORAGE_KEY
-      );
-    if (generateSuggestionStoredContent?.message === 'false') {
-      this.generateSuggestion = false;
+    if (this.comment?.id) {
+      const generateSuggestionStoredContent =
+        this.getStorage().getEditableContentItem(
+          ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id
+        );
+      if (generateSuggestionStoredContent?.message === 'false') {
+        this.generateSuggestion = false;
+      }
     }
   }
 
@@ -631,6 +683,10 @@
           /* Making up for the 2px reduced height above. */
           top: 1px;
         }
+        gr-suggestion-diff-preview,
+        gr-fix-suggestions {
+          margin-top: var(--spacing-s);
+        }
       `,
     ];
   }
@@ -661,8 +717,8 @@
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderHumanActions()} ${this.renderRobotActions()}
           </div>
-          ${this.renderGeneratedSuggestionPreview()}
-          ${this.renderFixSuggestionPreview()}
+          ${/* if this.editing */ this.renderGeneratedSuggestionPreview()}
+          ${/* if !this.editing */ this.renderFixSuggestionPreview()}
         </div>
       </gr-endpoint-decorator>
       ${this.renderConfirmDialog()}
@@ -845,7 +901,7 @@
   private renderEditingTextarea() {
     if (!this.editing || this.collapsed) return;
     return html`
-      <gr-textarea
+      <gr-suggestion-textarea
         id="editTextarea"
         class="editMessage"
         autocomplete="on"
@@ -853,19 +909,97 @@
         rows="4"
         .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
-        @text-changed=${(e: ValueChangedEvent) => {
-          // TODO: This is causing a re-render of <gr-comment> on every key
-          // press. Try to avoid always setting `this.messageText` or at least
-          // debounce it. Most of the code can just inspect the current value
-          // of the textare instead of needing a dedicated property.
-          this.messageText = e.detail.value;
-          this.autoSaveTrigger$.next();
-          this.generateSuggestionTrigger$.next();
-        }}
-      ></gr-textarea>
+        autocompleteHint=${this.autocompleteHint?.commentCompletion ?? ''}
+        @text-changed=${this.handleTextChanged}
+        @hintShown=${this.handleHintShown}
+        @hintApplied=${this.handleHintApplied}
+      ></gr-suggestion-textarea>
     `;
   }
 
+  private handleHintShown(e: CustomEvent<HintShownEventDetail>) {
+    const context = this.autocompleteCache.get(e.detail.oldValue);
+    if (context?.commentCompletion !== e.detail.hint) return;
+
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SUGGESTION_SHOWN,
+      context
+    );
+  }
+
+  private handleHintApplied(e: CustomEvent<HintAppliedEventDetail>) {
+    const context = this.autocompleteCache.get(e.detail.oldValue);
+    if (context?.commentCompletion !== e.detail.hint) return;
+
+    this.autocompleteAcceptedHints.push(e.detail.hint);
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SUGGESTION_ACCEPTED,
+      context
+    );
+  }
+
+  private reportHintInteractionSaved() {
+    if (this.autocompleteAcceptedHints.length === 0) return;
+    const content = this.messageText.trimEnd();
+    const acceptedHintsConcatenated = this.autocompleteAcceptedHints.join('');
+    const numExtraCharacters =
+      content.length - acceptedHintsConcatenated.length;
+    let distance = levenshteinDistance(acceptedHintsConcatenated, content);
+    if (numExtraCharacters > 0) {
+      distance -= numExtraCharacters;
+    }
+    const context = {
+      ...this.createAutocompletionBaseContext(),
+      similarCharacters: acceptedHintsConcatenated.length - distance,
+      maxSimilarCharacters: acceptedHintsConcatenated.length,
+      acceptedSuggestionsCount: this.autocompleteAcceptedHints.length,
+      totalAcceptedCharacters: acceptedHintsConcatenated.length,
+      savedDraftLength: content.length,
+    };
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SAVE_DRAFT,
+      context
+    );
+  }
+
+  private reportHintInteraction(
+    interaction: Interaction,
+    context: Partial<AutocompletionContext>
+  ) {
+    context = {
+      ...context,
+      draftContent: '[REDACTED]',
+      commentCompletion: '[REDACTED]',
+    };
+    this.reporting.reportInteraction(interaction, context);
+  }
+
+  private handleTextChanged(e: ValueChangedEvent) {
+    const oldValue = this.messageText;
+    const newValue = e.detail.value;
+    if (oldValue === newValue) return;
+    // TODO: This is causing a re-render of <gr-comment> on every key
+    // press. Try to avoid always setting `this.messageText` or at least
+    // debounce it. Most of the code can just inspect the current value
+    // of the textare instead of needing a dedicated property.
+    this.messageText = newValue;
+
+    this.handleTextChangedForAutocomplete();
+    this.autoSaveTrigger$.next();
+    this.generateSuggestionTrigger$.next();
+  }
+
+  // visible for testing
+  handleTextChangedForAutocomplete() {
+    const cachedHint = this.autocompleteCache.get(this.messageText);
+    if (cachedHint) {
+      this.autocompleteHint = cachedHint;
+    } else {
+      this.autocompleteHint = undefined;
+      this.autocompleteTrigger$.next();
+    }
+  }
+
   private renderCommentMessage() {
     if (this.collapsed || this.editing) return;
 
@@ -883,6 +1017,7 @@
   private renderCopyLinkIcon() {
     // Only show the icon when the thread contains a published comment.
     if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
+    if (this.editing) return;
     return html`
       <gr-icon
         icon="link"
@@ -926,6 +1061,7 @@
         ${this.renderDiscardButton()} ${this.renderEditButton()}
         ${this.renderCancelButton()} ${this.renderSaveButton()}
         ${this.renderCopyLinkIcon()}
+        <gr-endpoint-slot name="draft-actions-end"></gr-endpoint-slot>
       </div>
     `;
   }
@@ -999,17 +1135,21 @@
   }
 
   private renderFixSuggestionPreview() {
-    if (!this.comment?.fix_suggestions || this.editing) return nothing;
-    return html`<gr-suggestion-diff-preview
-      .fixReplacementInfos=${this.comment?.fix_suggestions?.[0].replacements}
-    ></gr-suggestion-diff-preview>`;
+    if (
+      !this.comment?.fix_suggestions ||
+      this.editing ||
+      isRobot(this.comment) ||
+      this.collapsed
+    )
+      return nothing;
+    return html`<gr-fix-suggestions
+      .comment=${this.comment}
+    ></gr-fix-suggestions>`;
   }
 
   // private but used in test
   showGeneratedSuggestion() {
     return (
-      (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
-        this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)) &&
       this.suggestionsProvider &&
       this.editing &&
       !this.permanentEditingMode &&
@@ -1029,19 +1169,24 @@
   }
 
   private renderGeneratedSuggestionPreview() {
-    if (!this.showGeneratedSuggestion() || !this.generateSuggestion)
+    if (
+      !this.editing ||
+      !this.showGeneratedSuggestion() ||
+      !this.generateSuggestion
+    )
       return nothing;
     if (!isDraft(this.comment)) return nothing;
 
     if (this.generatedFixSuggestion) {
       return html`<gr-suggestion-diff-preview
-        .fixReplacementInfos=${this.generatedFixSuggestion.replacements}
-      ></gr-suggestion-diff-preview>`;
-    } else if (this.generatedSuggestion) {
-      return html`<gr-suggestion-diff-preview
-        .showAddSuggestionButton=${true}
-        .suggestion=${this.generatedSuggestion?.replacement}
+        id="suggestionDiffPreview"
         .uuid=${this.generatedSuggestionId}
+        .fixSuggestionInfo=${this.generatedFixSuggestion}
+        .patchSet=${this.comment?.patch_set}
+        .commentId=${this.comment?.id}
+        @preview-loaded=${(event: CustomEvent<PreviewLoadedDetail>) =>
+          (this.previewedGeneratedFixSuggestion =
+            event.detail.previewLoadedFor)}
       ></gr-suggestion-diff-preview>`;
     } else {
       return nothing;
@@ -1063,21 +1208,17 @@
             ?checked=${this.generateSuggestion}
             @change=${() => {
               this.generateSuggestion = !this.generateSuggestion;
-              this.getStorage().setEditableContentItem(
-                ENABLE_GENERATE_SUGGESTION_STORAGE_KEY,
-                this.generateSuggestion.toString()
-              );
+              if (this.comment?.id) {
+                this.getStorage().setEditableContentItem(
+                  ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id,
+                  this.generateSuggestion.toString()
+                );
+              }
               if (this.generateSuggestion) {
                 this.generateSuggestionTrigger$.next();
               } else {
-                if (
-                  this.flagsService.isEnabled(
-                    KnownExperimentId.ML_SUGGESTED_EDIT_V2
-                  )
-                ) {
-                  this.generatedFixSuggestion = undefined;
-                  this.autoSaveTrigger$.next();
-                }
+                this.generatedFixSuggestion = undefined;
+                this.autoSaveTrigger$.next();
               }
               this.reporting.reportInteraction(
                 this.generateSuggestion
@@ -1086,7 +1227,7 @@
               );
             }}
           />
-          Generate Suggestion
+          Attach AI-suggested fix
           ${when(
             this.suggestionLoading,
             () => html`<span class="loadingSpin"></span>`,
@@ -1094,9 +1235,10 @@
           )}
         </label>
         <a
-          href=${getDocUrl(
+          href=${this.suggestionsProvider?.getDocumentationLink?.() ||
+          getDocUrl(
             this.docsBaseUrl,
-            'user-suggest-edits.html#_generate_suggestion'
+            'user-suggest-edits.html$_generate_suggestion'
           )}
           target="_blank"
           rel="noopener noreferrer"
@@ -1114,82 +1256,14 @@
     if (!this.generateSuggestion) {
       return '';
     }
-    if (this.generatedSuggestion || this.generatedFixSuggestion) {
+    if (this.generatedFixSuggestion) {
       return '(1)';
     } else {
       return '(0)';
     }
   }
 
-  private handleAddGeneratedSuggestion(code: string) {
-    const addNewLine = this.messageText.length !== 0;
-    this.messageText += `${
-      addNewLine ? '\n' : ''
-    }${USER_SUGGESTION_START_PATTERN}${code}${'\n```'}`;
-  }
-
-  private generateSuggestEdit() {
-    if (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)) {
-      this.generateSuggestEdit_v2();
-    } else if (
-      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT)
-    ) {
-      this.generateSuggestEdit_v1();
-    }
-  }
-
-  private async generateSuggestEdit_v1() {
-    const suggestionsProvider = this.suggestionsProvider;
-    const changeInfo = this.getChangeModel().getChange();
-    if (
-      !suggestionsProvider?.suggestCode ||
-      !this.showGeneratedSuggestion() ||
-      !this.generateSuggestion ||
-      !changeInfo ||
-      !this.comment ||
-      !this.comment.patch_set ||
-      !this.comment.path ||
-      this.messageText.length === 0
-    )
-      return;
-    this.generatedSuggestionId = uuid();
-    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_REQUEST, {
-      uuid: this.generatedSuggestionId,
-      type: 'suggest-code',
-      commentId: this.comment.id,
-    });
-    this.suggestionLoading = true;
-    let suggestionResponse;
-    try {
-      suggestionResponse = await suggestionsProvider.suggestCode({
-        prompt: this.messageText,
-        changeInfo: changeInfo as ChangeInfo,
-        patchsetNumber: this.comment?.patch_set,
-        filePath: this.comment.path,
-        range: this.comment.range,
-        lineNumber: this.comment.line,
-      });
-    } finally {
-      this.suggestionLoading = false;
-    }
-
-    if (!suggestionResponse) return;
-    // TODO(milutin): The suggestionResponse can contain multiple suggestion
-    // options. We pick the first one for now. In future we shouldn't ignore
-    // other suggestions.
-    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
-      uuid: this.generatedSuggestionId,
-      type: 'suggest-code',
-      response: suggestionResponse.responseCode,
-      numSuggestions: suggestionResponse.suggestions.length,
-      hasNewRange: suggestionResponse.suggestions?.[0]?.newRange !== undefined,
-    });
-    const suggestion = suggestionResponse.suggestions?.[0];
-    if (!suggestion?.replacement) return;
-    this.generatedSuggestion = suggestion;
-  }
-
-  private async generateSuggestEdit_v2() {
+  private async generateSuggestEdit() {
     const suggestionsProvider = this.suggestionsProvider;
     const changeInfo = this.getChangeModel().getChange();
     if (
@@ -1208,6 +1282,7 @@
       uuid: this.generatedSuggestionId,
       type: 'suggest-fix',
       commentId: this.comment.id,
+      fileExtension: getFileExtension(this.comment.path ?? ''),
     });
     this.suggestionLoading = true;
     let suggestionResponse;
@@ -1231,15 +1306,101 @@
     this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
       uuid: this.generatedSuggestionId,
       type: 'suggest-fix',
+      commentId: this.comment.id,
       response: suggestionResponse.responseCode,
       numSuggestions: suggestionResponse.fix_suggestions.length,
+      fileExtension: getFileExtension(this.comment.path ?? ''),
+      logProbability: suggestionResponse.fix_suggestions?.[0].log_probability,
     });
     const suggestion = suggestionResponse.fix_suggestions?.[0];
     if (!suggestion?.replacements || suggestion.replacements.length === 0) {
       return;
     }
     this.generatedFixSuggestion = suggestion;
-    this.autoSaveTrigger$.next();
+
+    try {
+      await waitUntil(() => this.getFixSuggestions() !== undefined);
+      this.autoSaveTrigger$.next();
+    } catch (error) {
+      // Error is ok in some cases like quick save by user.
+      console.warn(error);
+    }
+  }
+
+  private async autocompleteComment() {
+    const enabled = this.flagsService.isEnabled(
+      KnownExperimentId.COMMENT_AUTOCOMPLETION
+    );
+    const suggestionsProvider = this.suggestionsProvider;
+    const change = this.getChangeModel().getChange();
+    if (
+      !enabled ||
+      !this.autocompleteEnabled ||
+      !suggestionsProvider?.autocompleteComment ||
+      !change ||
+      !this.comment?.patch_set ||
+      !this.comment.path ||
+      this.messageText.length === 0
+    ) {
+      return;
+    }
+    const commentText = this.messageText;
+    this.reporting.time(Timing.COMMENT_COMPLETION);
+    const response = await suggestionsProvider.autocompleteComment({
+      id: id(this.comment),
+      commentText,
+      changeInfo: change as ChangeInfo,
+      patchsetNumber: this.comment?.patch_set,
+      filePath: this.comment.path,
+      range: this.comment.range,
+      lineNumber: this.comment.line,
+    });
+    const elapsed = this.reporting.timeEnd(Timing.COMMENT_COMPLETION);
+    const context = this.createAutocompletionContext(
+      commentText,
+      response,
+      elapsed
+    );
+    this.reportHintInteraction(
+      Interaction.COMMENT_COMPLETION_SUGGESTION_FETCHED,
+      {...context, hasDraftChanged: this.messageText !== commentText}
+    );
+    if (!response?.completion) return;
+    // Note that we are setting the cache value for `commentText` and getting the value
+    // for `this.messageText`.
+    this.autocompleteCache.set(context);
+    this.autocompleteHint = this.autocompleteCache.get(this.messageText);
+  }
+
+  private createAutocompletionBaseContext(): Partial<AutocompletionContext> {
+    return {
+      commentId: id(this.comment!),
+      commentNumber: this.comments?.length ?? 0,
+      filePath: this.comment!.path,
+      fileExtension: getFileExtension(this.comment!.path ?? ''),
+    };
+  }
+
+  private createAutocompletionContext(
+    draftContent: string,
+    response: AutocompleteCommentResponse,
+    requestDurationMs: number
+  ): AutocompletionContext {
+    const commentCompletion = response.completion ?? '';
+    return {
+      ...this.createAutocompletionBaseContext(),
+
+      draftContent,
+      draftContentLength: draftContent.length,
+      commentCompletion,
+      commentCompletionLength: commentCompletion.length,
+
+      isFullCommentPrediction: draftContent.length === 0,
+      draftInSyncWithSuggestionLength: 0,
+      modelVersion: response.modelVersion ?? '',
+      outcome: response.outcome,
+      requestDurationMs,
+    };
   }
 
   private renderRobotActions() {
@@ -1336,16 +1497,8 @@
         whenVisible(this, () => this.textarea?.putCursorAtEnd());
       }
     }
-    if (
-      changed.has('changeNum') ||
-      changed.has('comment') ||
-      changed.has('generatedSuggestion')
-    ) {
-      if (
-        !this.changeNum ||
-        !this.comment ||
-        (!hasUserSuggestion(this.comment) && !this.generatedSuggestion)
-      )
+    if (changed.has('changeNum') || changed.has('comment')) {
+      if (!this.changeNum || !this.comment || !hasUserSuggestion(this.comment))
         return;
       (async () => {
         this.commentedText = await this.commentModel.getCommentedCode(
@@ -1649,9 +1802,9 @@
     if (!this.comment) return false;
     return (
       isError(this.comment) ||
-      this.messageText.trimEnd() !== this.comment?.message ||
+      this.messageText.trimEnd() !== this.comment.message ||
       this.unresolved !== this.comment.unresolved ||
-      !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions())
+      this.isFixSuggestionChanged()
     );
   }
 
@@ -1659,23 +1812,31 @@
   private rawSave(options: {showToast: boolean}) {
     assert(isDraft(this.comment), 'only drafts are editable');
     assert(!isSaving(this.comment), 'saving already in progress');
-    return this.getCommentsModel().saveDraft(
-      {
-        ...this.comment,
-        message: this.messageText.trimEnd(),
-        unresolved: this.unresolved,
-        fix_suggestions: this.getFixSuggestions(),
-      },
-      options.showToast
-    );
+    const draft: DraftInfo = {
+      ...this.comment,
+      message: this.messageText.trimEnd(),
+      unresolved: this.unresolved,
+    };
+    if (this.isFixSuggestionChanged()) {
+      draft.fix_suggestions = this.getFixSuggestions();
+    }
+    this.reportHintInteractionSaved();
+    return this.getCommentsModel().saveDraft(draft, options.showToast);
+  }
+
+  isFixSuggestionChanged(): boolean {
+    return !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions());
   }
 
   getFixSuggestions(): FixSuggestionInfo[] | undefined {
-    if (!this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2))
-      return undefined;
     if (!this.generateSuggestion) return undefined;
     if (!this.generatedFixSuggestion) return undefined;
-    return [this.generatedFixSuggestion];
+    // Disable fix suggestions when the comment already has a user suggestion
+    if (this.comment && hasUserSuggestion(this.comment)) return undefined;
+    // we ignore fixSuggestions until they are previewed.
+    if (this.previewedGeneratedFixSuggestion)
+      return [this.previewedGeneratedFixSuggestion];
+    return undefined;
   }
 
   private handleToggleResolved() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 4c32da7..84eee96 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-comment';
 import {AUTO_SAVE_DEBOUNCE_DELAY_MS, GrComment} from './gr-comment';
@@ -39,8 +40,8 @@
 import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {assertIsDefined} from '../../../utils/common-util';
-import {Modifier} from '../../../utils/dom-util';
-import {SinonStubbedMember} from 'sinon';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {SinonStub, SinonStubbedMember} from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
@@ -48,7 +49,7 @@
   CommentsModel,
   commentsModelToken,
 } from '../../../models/comments/comments-model';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 
 suite('gr-comment tests', () => {
   let element: GrComment;
@@ -241,7 +242,6 @@
                   </gr-button>
                 </div>
               </div>
-              <gr-suggestion-diff-preview></gr-suggestion-diff-preview>
             </div>
           </gr-endpoint-decorator>
           <dialog id="confirmDeleteModal" tabindex="-1">
@@ -343,6 +343,8 @@
                     >
                       Edit
                     </gr-button>
+                    <gr-endpoint-slot name="draft-actions-end">
+                    </gr-endpoint-slot>
                   </div>
                 </div>
               </div>
@@ -407,15 +409,16 @@
                 </div>
               </div>
               <div class="body">
-                <gr-textarea
+                <gr-suggestion-textarea
                   autocomplete="on"
+                  autocompletehint=""
                   class="code editMessage"
                   code=""
                   id="editTextarea"
                   rows="4"
                   text="This is the test comment message."
                 >
-                </gr-textarea>
+                </gr-suggestion-textarea>
                 <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
                 <div class="actions">
                   <div class="leftActions">
@@ -445,6 +448,8 @@
                     >
                       Save
                     </gr-button>
+                    <gr-endpoint-slot name="draft-actions-end">
+                    </gr-endpoint-slot>
                   </div>
                 </div>
               </div>
@@ -593,10 +598,46 @@
       element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
       await element.updateComplete;
-      pressKey(element.textarea!.textarea!.textarea, 's', Modifier.CTRL_KEY);
+      pressKey(element.textarea!, 's', Modifier.CTRL_KEY);
       assert.isTrue(spy.called);
     });
 
+    suite('ctrl+ENTER  ', () => {
+      test('saves comment', async () => {
+        const spy = sinon.stub(element, 'save');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        pressKey(element.textarea!, Key.ENTER, Modifier.CTRL_KEY);
+        assert.isTrue(spy.called);
+      });
+      test('propagates on patchset comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.permanentEditingMode = true;
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isFalse(stopPropagationStub.called);
+      });
+      test('does not propagate on normal comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isTrue(stopPropagationStub.called);
+      });
+    });
+
     test('save', async () => {
       const savePromise = mockPromise<DraftInfo>();
       const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
@@ -856,6 +897,44 @@
     });
   });
 
+  suite('handleTextChangedForAutocomplete', () => {
+    test('foo -> foo with asdf', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'foo';
+      element.handleTextChangedForAutocomplete();
+      assert.equal(element.autocompleteHint.commentCompletion, 'asdf');
+    });
+
+    test('foo -> bar with asdf', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'bar';
+      element.handleTextChangedForAutocomplete();
+      assert.isUndefined(element.autocompleteHint);
+    });
+
+    test('foo -> foofoo with asdf', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'foofoo';
+      element.handleTextChangedForAutocomplete();
+      assert.isUndefined(element.autocompleteHint);
+    });
+
+    test('foo -> foofoo with foomore', async () => {
+      const ctx = {draftContent: 'foo', commentCompletion: 'foomore'};
+      element.autocompleteHint = ctx;
+      element.autocompleteCache.set(ctx);
+      element.messageText = 'foofoo';
+      element.handleTextChangedForAutocomplete();
+      assert.equal(element.autocompleteHint.commentCompletion, 'more');
+    });
+  });
+
   suite('suggest edit', () => {
     let element: GrComment;
     setup(async () => {
@@ -926,11 +1005,6 @@
         },
       ],
     };
-    setup(async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
-        .returns(true);
-    });
 
     test('renders suggestions in comment', async () => {
       const comment = {
@@ -956,8 +1030,8 @@
       element.editing = false;
       await element.updateComplete;
       assert.dom.equal(
-        queryAndAssert(element, 'gr-suggestion-diff-preview'),
-        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+        queryAndAssert(element, 'gr-fix-suggestions'),
+        /* HTML */ '<gr-fix-suggestions> </gr-fix-suggestions>'
       );
     });
 
@@ -985,8 +1059,8 @@
       element.editing = false;
       await element.updateComplete;
       assert.dom.equal(
-        queryAndAssert(element, 'gr-suggestion-diff-preview'),
-        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+        queryAndAssert(element, 'gr-fix-suggestions'),
+        /* HTML */ '<gr-fix-suggestions> </gr-fix-suggestions>'
       );
     });
 
@@ -1035,15 +1109,92 @@
           .initiallyCollapsed=${false}
         ></gr-comment>`
       );
-      element.editing = false;
+      element.editing = true;
       sinon.stub(element, 'showGeneratedSuggestion').returns(true);
       element.generateSuggestion = true;
       element.generatedFixSuggestion = generatedFixSuggestion;
       await element.updateComplete;
       assert.dom.equal(
         queryAndAssert(element, 'gr-suggestion-diff-preview'),
-        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+        /* HTML */ '<gr-suggestion-diff-preview id="suggestionDiffPreview"> </gr-suggestion-diff-preview>'
       );
     });
+
+    suite('save', () => {
+      const savePromise = mockPromise<DraftInfo>();
+      let saveDraftStub: SinonStub;
+      const textToSave = 'something, not important';
+      setup(async () => {
+        const comment = createDraft();
+        element = await fixture(
+          html`<gr-comment
+            .account=${account}
+            .showPatchset=${true}
+            .comment=${comment}
+            .initiallyCollapsed=${false}
+          ></gr-comment>`
+        );
+        saveDraftStub = sinon
+          .stub(commentsModel, 'saveDraft')
+          .returns(savePromise);
+        sinon.stub(element, 'showGeneratedSuggestion').returns(true);
+        element.editing = true;
+        await element.updateComplete;
+        element.messageText = textToSave;
+        element.unresolved = true;
+        element.generateSuggestion = true;
+        element.generatedFixSuggestion = generatedFixSuggestion;
+        await element.updateComplete;
+      });
+
+      test('save fix suggestion when previewed', async () => {
+        const suggestionDiffPreview = queryAndAssert<GrSuggestionDiffPreview>(
+          element,
+          '#suggestionDiffPreview'
+        );
+        suggestionDiffPreview.previewed = true;
+        suggestionDiffPreview.previewLoadedFor = generatedFixSuggestion;
+        await element.updateComplete;
+        // trigger event preview-loaded on suggestionDiffPreview with detail
+        suggestionDiffPreview.dispatchEvent(
+          new CustomEvent('preview-loaded', {
+            bubbles: true,
+            detail: {previewLoadedFor: generatedFixSuggestion},
+          })
+        );
+        // await element.waitPreviewForGeneratedSuggestion();
+        await element.updateComplete;
+        element.save();
+        await element.updateComplete;
+        waitUntilCalled(saveDraftStub, 'saveDraft()');
+        assert.equal(
+          saveDraftStub.lastCall.firstArg.fix_suggestions[0]?.fix_id,
+          generatedFixSuggestion.fix_id
+        );
+        assert.isFalse(element.editing);
+
+        savePromise.resolve();
+      });
+
+      test("don't save fix suggestion when not previewed", async () => {
+        const suggestionDiffPreview = queryAndAssert<GrSuggestionDiffPreview>(
+          element,
+          '#suggestionDiffPreview'
+        );
+        suggestionDiffPreview.previewed = false;
+        await element.updateComplete;
+        element.save();
+        await element.updateComplete;
+        waitUntilCalled(saveDraftStub, 'saveDraft()');
+        assert.equal(saveDraftStub.lastCall.firstArg.message, textToSave);
+        assert.equal(
+          saveDraftStub.lastCall.firstArg.fix_suggestions,
+          undefined
+        );
+        assert.isFalse(element.editing);
+
+        savePromise.resolve();
+      });
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index ef01dad..7e476eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-copy-clipboard';
 import {GrCopyClipboard} from './gr-copy-clipboard';
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
index 81d2b45..b03a421 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AbortStop, CursorMoveResult} from '../../../api/core';
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
index 98f2d82..d6a01ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-date-formatter';
 import {GrDateFormatter} from './gr-date-formatter';
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index 41fcfed..c4928d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-dialog';
 import {GrDialog} from './gr-dialog';
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index babe5e2..2ab3dd5 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -3,11 +3,9 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Subscription} from 'rxjs';
 import '@polymer/paper-tabs/paper-tab';
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
-import {getAppContext} from '../../../services/app-context';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
 import {paperStyles} from '../../../styles/gr-paper-styles';
@@ -18,6 +16,7 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 declare global {
   interface HTMLElementEventMap {
@@ -55,37 +54,29 @@
   @property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
   showKeyboardShortcutTooltips = false;
 
-  private readonly restApiService = getAppContext().restApiService;
-
   // Private but used in tests.
   readonly getUserModel = resolve(this, userModelToken);
 
-  private subscriptions: Subscription[] = [];
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.subscriptions.push(
-      this.getUserModel().preferences$.subscribe(prefs => {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
         if (prefs?.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this.selectedScheme = prefs.download_scheme.toLowerCase();
           fire(this, 'selected-scheme-changed', {value: this.selectedScheme});
         }
-      })
+      }
     );
   }
 
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-    super.disconnectedCallback();
-  }
-
   static override get styles() {
     return [
       paperStyles,
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index b1d4e36..2f656b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-download-commands';
 import {GrDownloadCommands} from './gr-download-commands';
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index c148a1b..fb150fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-dropdown-list';
 import {GrDropdownList} from './gr-dropdown-list';
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index e9ef52b..3a01748 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-dropdown';
 import {GrDropdown} from './gr-dropdown';
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 92505d2..eeccda6 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -12,10 +12,11 @@
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
 import {subscribe} from '../../lit/subscription-controller';
 import {changeModelToken} from '../../../models/change/change-model';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {fire, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {Interaction} from '../../../constants/reporting';
 import {LitElement, html} from 'lit';
@@ -28,7 +29,13 @@
   EditableContentSaveEvent,
   ValueChangedEvent,
 } from '../../../types/events';
-import {EmailInfo, GitPersonInfo} from '../../../types/common';
+import {
+  EmailInfo,
+  GitPersonInfo,
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../../types/common';
 import {nothing} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
@@ -36,6 +43,8 @@
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {resolve} from '../../../models/dependency';
 import {formStyles} from '../../../styles/form-styles';
+import {changeViewModelToken} from '../../../models/views/change';
+import {SpecialFilePath} from '../../../constants/constants';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -101,6 +110,14 @@
   @state()
   latestCommitter?: GitPersonInfo;
 
+  @state() editMode = false;
+
+  @state() repoName?: RepoName;
+
+  @state() changeNum?: NumericChangeId;
+
+  @state() patchNum?: RevisionPatchSetNum;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getChangeModel = resolve(this, changeModelToken);
@@ -109,6 +126,10 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
@@ -119,6 +140,26 @@
       () => this.getChangeModel().latestCommitter$,
       x => (this.latestCommitter = x)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      editMode => (this.editMode = editMode)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      x => (this.patchNum = x)
+    );
   }
 
   override disconnectedCallback() {
@@ -458,6 +499,19 @@
   }
 
   async handleEditCommitMessage() {
+    if (this.editMode) {
+      assertIsDefined(this.changeNum, 'changeNum');
+      assertIsDefined(this.repoName, 'repoName');
+      assertIsDefined(this.patchNum, 'patchNum');
+      this.getNavigation().setUrl(
+        this.getViewModel().editUrl({
+          editView: {path: SpecialFilePath.COMMIT_MESSAGE},
+          patchNum: this.patchNum,
+        })
+      );
+
+      return;
+    }
     await this.loadEmails();
     this.editing = true;
     await this.updateComplete;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index f8f530d..3d2469a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-editable-content';
 import {GrEditableContent} from './gr-editable-content';
@@ -13,14 +14,22 @@
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {testResolver} from '../../../test/common-test-setup';
 import {GrDropdownList} from '../gr-dropdown-list/gr-dropdown-list';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  EmailAddress,
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../../api/rest-api';
+import {changeViewModelToken} from '../../../models/views/change';
 
 const emails = [
   {
-    email: 'primary@example.com',
+    email: 'primary@example.com' as EmailAddress,
     preferred: true,
   },
   {
-    email: 'secondary@example.com',
+    email: 'secondary@example.com' as EmailAddress,
     preferred: false,
   },
 ];
@@ -180,6 +189,29 @@
         queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
       );
     });
+
+    suite('in editMode', () => {
+      test('click opens edit url', async () => {
+        const editUrlStub = sinon.stub(
+          testResolver(changeViewModelToken),
+          'editUrl'
+        );
+        editUrlStub.returns('fakeUrl');
+        const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+        element.editMode = true;
+        element.changeNum = 42 as NumericChangeId;
+        element.repoName = 'Test Repo' as RepoName;
+        element.patchNum = '1' as RevisionPatchSetNum;
+        await element.updateComplete;
+        const editButton = queryAndAssert<GrButton>(
+          element,
+          'gr-button.edit-commit-message'
+        );
+        editButton.click();
+        assert.isTrue(setUrlStub.called);
+        assert.equal(setUrlStub.lastCall.args[0], 'fakeUrl');
+      });
+    });
   });
 
   suite('storageKey and related behavior', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index 3bb058e..bb7989c 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-editable-label';
 import {GrEditableLabel} from './gr-editable-label';
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
new file mode 100644
index 0000000..137c853
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, state, query, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {getDocUrl} from '../../../utils/url-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {changeModelToken} from '../../../models/change/change-model';
+import {Comment, PatchSetNumber} from '../../../types/common';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
+import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import {SuggestionsProvider} from '../../../api/suggestions';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
+import {when} from 'lit/directives/when.js';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {getAppContext} from '../../../services/app-context';
+import {Interaction} from '../../../constants/reporting';
+
+export const COLLAPSE_SUGGESTION_STORAGE_KEY = 'collapseSuggestionStorageKey';
+
+/**
+ * gr-fix-suggestions is UI for comment.fix_suggestions.
+ * gr-fix-suggestions is wrapper for gr-suggestion-diff-preview with buttons
+ * to preview and apply fix and for giving a context about suggestion.
+ */
+@customElement('gr-fix-suggestions')
+export class GrFixSuggestions extends LitElement {
+  @query('gr-suggestion-diff-preview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
+  @property({type: Object})
+  comment?: Comment;
+
+  @state() private docsBaseUrl = '';
+
+  @state() private applyingFix = false;
+
+  @state() latestPatchNum?: PatchSetNumber;
+
+  @state()
+  suggestionsProvider?: SuggestionsProvider;
+
+  @state() private isOwner = false;
+
+  /**
+   * This is just a reflected property such that css rules can be based on it.
+   */
+  @property({type: Boolean, reflect: true})
+  collapsed = false;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly getStorage = resolve(this, storageServiceToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  @state() private previewLoaded = false;
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        const suggestionsPlugins =
+          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+        // We currently support results from only 1 provider.
+        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
+      });
+
+    if (this.comment?.id) {
+      const generateSuggestionStoredContent =
+        this.getStorage().getEditableContentItem(
+          COLLAPSE_SUGGESTION_STORAGE_KEY + this.comment.id
+        );
+      if (generateSuggestionStoredContent?.message === 'true') {
+        this.collapsed = true;
+      }
+    }
+  }
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+        }
+        :host([collapsed]) gr-suggestion-diff-preview {
+          display: none;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+        }
+        /* just for a11y */
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+        }
+        label.show-hide gr-icon {
+          vertical-align: top;
+        }
+        .header {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          padding: var(--spacing-xs) var(--spacing-xl);
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          border-top-left-radius: var(--border-radius);
+          border-top-right-radius: var(--border-radius);
+        }
+        .header .title {
+          flex: 1;
+        }
+        .headerMiddle {
+          display: flex;
+          align-items: center;
+        }
+        .copyButton {
+          margin-right: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.comment?.fix_suggestions) return;
+    const fix_suggestions = this.comment.fix_suggestions;
+    return html`<div class="header">
+        <div class="title">
+          <span
+            >${this.suggestionsProvider?.getFixSuggestionTitle?.(
+              fix_suggestions
+            ) || 'Suggested edit'}</span
+          >
+          <a
+            href=${this.suggestionsProvider?.getDocumentationLink?.(
+              fix_suggestions
+            ) || getDocUrl(this.docsBaseUrl, 'user-suggest-edits.html')}
+            target="_blank"
+            rel="noopener noreferrer"
+            ><gr-endpoint-decorator name="fix-suggestion-title-help">
+              <gr-endpoint-param
+                name="suggestion"
+                .value=${fix_suggestions}
+              ></gr-endpoint-param
+              ><gr-icon
+                icon="help"
+                title="read documentation"
+              ></gr-icon></gr-endpoint-decorator
+          ></a>
+        </div>
+        <div class="headerMiddle">
+          <gr-button
+            secondary
+            flatten
+            class="action show-fix"
+            @click=${this.handleShowFix}
+          >
+            Show edit
+          </gr-button>
+          ${when(
+            this.isOwner && !this.collapsed,
+            () =>
+              html`<gr-button
+                secondary
+                flatten
+                .loading=${this.applyingFix}
+                .disabled=${this.isApplyEditDisabled()}
+                class="action show-fix"
+                @click=${this.handleApplyFix}
+                .title=${this.computeApplyEditTooltip()}
+              >
+                Apply edit
+              </gr-button>`
+          )}
+          ${this.renderToggle()}
+        </div>
+      </div>
+      <gr-suggestion-diff-preview
+        .fixSuggestionInfo=${this.comment?.fix_suggestions?.[0]}
+        .patchSet=${this.comment?.patch_set}
+        .commentId=${this.comment?.id}
+        @preview-loaded=${() => (this.previewLoaded = true)}
+      ></gr-suggestion-diff-preview>`;
+  }
+
+  private renderToggle() {
+    const icon = this.collapsed ? 'expand_more' : 'expand_less';
+    const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
+    return html`
+      <div class="show-hide" tabindex="0">
+        <label class="show-hide" aria-label=${ariaLabel}>
+          <input
+            type="checkbox"
+            class="show-hide"
+            .checked=${this.collapsed}
+            @change=${() => {
+              this.collapsed = !this.collapsed;
+              if (this.collapsed) {
+                this.reporting.reportInteraction(
+                  Interaction.GENERATE_SUGGESTION_COLLAPSED
+                );
+              } else {
+                this.reporting.reportInteraction(
+                  Interaction.GENERATE_SUGGESTION_EXPANDED
+                );
+              }
+              if (this.comment?.id) {
+                this.getStorage().setEditableContentItem(
+                  COLLAPSE_SUGGESTION_STORAGE_KEY + this.comment.id,
+                  this.collapsed.toString()
+                );
+              }
+            }}
+          />
+          <gr-icon icon=${icon} id="icon"></gr-icon>
+        </label>
+      </div>
+    `;
+  }
+
+  handleShowFix() {
+    if (!this.comment?.fix_suggestions || !this.comment?.patch_set) return;
+    const eventDetail: OpenFixPreviewEventDetail = {
+      fixSuggestions: this.comment.fix_suggestions.map(s => {
+        return {
+          ...s,
+          fix_id: PROVIDED_FIX_ID,
+          description:
+            this.suggestionsProvider?.getFixSuggestionTitle?.(
+              this.comment?.fix_suggestions
+            ) || 'Suggested edit',
+        };
+      }),
+      patchNum: this.comment.patch_set,
+      onCloseFixPreviewCallbacks: [
+        fixApplied => {
+          if (fixApplied) fire(this, 'apply-user-suggestion', {});
+        },
+      ],
+    };
+    fire(this, 'open-fix-preview', eventDetail);
+  }
+
+  async handleApplyFix() {
+    if (!this.comment?.fix_suggestions) return;
+    this.applyingFix = true;
+    try {
+      await this.suggestionDiffPreview?.applyFix();
+    } finally {
+      this.applyingFix = false;
+    }
+  }
+
+  private isApplyEditDisabled() {
+    if (this.comment?.patch_set === undefined) return true;
+    return !this.previewLoaded;
+  }
+
+  private computeApplyEditTooltip() {
+    if (this.comment?.patch_set === undefined) return '';
+    if (!this.previewLoaded) return 'Fix is still loading ...';
+    return '';
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('comment') && this.comment?.fix_suggestions) {
+      this.previewLoaded = false;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-fix-suggestions': GrFixSuggestions;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index b57e4ff..900e9ed 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -126,7 +126,7 @@
         this.repoCommentLinks = repoCommentLinks;
         // Always linkify URLs starting with https?://
         this.repoCommentLinks['ALWAYS_LINK_HTTP'] = {
-          match: '(https?://((?!&(gt|lt|amp|quot|apos);)\\S)+[\\w/~-])',
+          match: '(https?://((?!&(gt|lt|quot|apos);)\\S)+[\\w/~-])',
           link: '$1',
           enabled: true,
         };
@@ -135,11 +135,13 @@
   }
 
   override render() {
-    if (this.markdown && this.content.length < this.MARKDOWN_LIMIT) {
-      return this.renderAsMarkdown();
-    } else {
-      return this.renderAsPlaintext();
-    }
+    return html`
+      <gr-endpoint-decorator name="formatted-text-endpoint">
+        ${this.markdown && this.content.length < this.MARKDOWN_LIMIT
+          ? this.renderAsMarkdown()
+          : this.renderAsPlaintext()}
+      </gr-endpoint-decorator>
+    `;
   }
 
   private renderAsPlaintext() {
@@ -305,11 +307,14 @@
 
   private convertCodeToSuggestions() {
     const marks = this.renderRoot.querySelectorAll('mark');
-    for (const userSuggestionMark of marks) {
+    marks.forEach((userSuggestionMark, index) => {
       const userSuggestion = document.createElement('gr-user-suggestion-fix');
       // Temporary workaround for bug - tabs replacement
       if (this.content.includes('\t')) {
-        userSuggestion.textContent = getUserSuggestionFromString(this.content);
+        userSuggestion.textContent = getUserSuggestionFromString(
+          this.content,
+          index
+        );
       } else {
         userSuggestion.textContent = userSuggestionMark.textContent ?? '';
       }
@@ -317,7 +322,7 @@
         userSuggestion,
         userSuggestionMark
       );
-    }
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 23f1594..723267e 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {assert, fixture, html} from '@open-wc/testing';
 import {changeModelToken} from '../../../models/change/change-model';
@@ -87,7 +88,8 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <pre class="plaintext">
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <pre class="plaintext">
             <a
               href="http://google.com/LinkRewriteMe"
               rel="noopener noreferrer"
@@ -96,6 +98,7 @@
             http://google.com/LinkRewriteMe
             </a>
           </pre>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -119,9 +122,11 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <pre class="plaintext">
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <pre class="plaintext">
           FOO<a href="a.b.c" rel="noopener noreferrer" target="_blank">foo</a>
         </pre>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -147,7 +152,8 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <pre class="plaintext">
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <pre class="plaintext">
             Start:
             <a href="bug/123" rel="noopener noreferrer" target="_blank">
               bug/123
@@ -156,6 +162,7 @@
               bug/456
             </a>
           </pre>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -170,7 +177,8 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <pre class="plaintext">
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <pre class="plaintext">
           text with plain link:
           <a
             href="http://google.com"
@@ -196,6 +204,7 @@
               Link 12
             </a>
           </pre>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -207,7 +216,9 @@
       const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
       assert.shadowDom.equal(
         element,
-        /* HTML */ `<pre class="plaintext">plain text ${escapedDiv}</pre>`
+        /* HTML */ `<gr-endpoint-decorator name="formatted-text-endpoint">
+          <pre class="plaintext">plain text ${escapedDiv}</pre>
+        </gr-endpoint-decorator>`
       );
     });
 
@@ -217,7 +228,10 @@
 
       assert.shadowDom.equal(
         element,
-        /* HTML */ '<pre class="plaintext"># A Markdown Heading</pre>'
+        /* HTML */
+        `<gr-endpoint-decorator name="formatted-text-endpoint">
+          <pre class="plaintext"># A Markdown Heading</pre>
+        </gr-endpoint-decorator>`
       );
     });
 
@@ -235,6 +249,11 @@
       await checkLinking('https://www.google.com/');
       await checkLinking('https://www.google.com/asdf~');
       await checkLinking('https://www.google.com/asdf-');
+      await checkLinking('https://www.google.com/asdf-');
+      // matches & part as well, even we first linkify and then htmlEscape
+      await checkLinking(
+        'https://google.com/traces/list?project=gerrit&tid=123'
+      );
     });
   });
 
@@ -254,42 +273,44 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>text</p>
-              <p>
-                text with plain link:
-                <a
-                  href="http://google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                >
-                  http://google.com
-                </a>
-              </p>
-              <p>
-                text with config link:
-                <a
-                  href="http://google.com/LinkRewriteMe"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                >
-                  LinkRewriteMe
-                </a>
-              </p>
-              <p>text without a link: NotA Link 15 cats</p>
-              <p>
-                text with complex link: A
-                <a
-                  href="http://localhost/page?id=12"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                >
-                  Link 12
-                </a>
-              </p>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>text</p>
+                <p>
+                  text with plain link:
+                  <a
+                    href="http://google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                  >
+                    http://google.com
+                  </a>
+                </p>
+                <p>
+                  text with config link:
+                  <a
+                    href="http://google.com/LinkRewriteMe"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                  >
+                    LinkRewriteMe
+                  </a>
+                </p>
+                <p>text without a link: NotA Link 15 cats</p>
+                <p>
+                  text with complex link: A
+                  <a
+                    href="http://localhost/page?id=12"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                  >
+                    Link 12
+                  </a>
+                </p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -306,7 +327,8 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <pre class="plaintext">
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <pre class="plaintext">
           text
         text with plain link:
         <a
@@ -334,6 +356,7 @@
             Link 12
           </a>
         </pre>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -352,36 +375,38 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <h1>h1-heading</h1>
-              <h2>h2-heading</h2>
-              <h3>h3-heading</h3>
-              <h4>h4-heading</h4>
-              <h5>h5-heading</h5>
-              <h6>h6-heading</h6>
-              <h1>
-                heading with plain link:
-                <a
-                  href="http://google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                >
-                  http://google.com
-                </a>
-              </h1>
-              <h1>
-                heading with config link:
-                <a
-                  href="http://google.com/LinkRewriteMe"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                >
-                  LinkRewriteMe
-                </a>
-              </h1>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <h1>h1-heading</h1>
+                <h2>h2-heading</h2>
+                <h3>h3-heading</h3>
+                <h4>h4-heading</h4>
+                <h5>h5-heading</h5>
+                <h6>h6-heading</h6>
+                <h1>
+                  heading with plain link:
+                  <a
+                    href="http://google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                  >
+                    http://google.com
+                  </a>
+                </h1>
+                <h1>
+                  heading with config link:
+                  <a
+                    href="http://google.com/LinkRewriteMe"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                  >
+                    LinkRewriteMe
+                  </a>
+                </h1>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -395,19 +420,21 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                <code>inline code</code>
-              </p>
-              <p>
-                <code>inline code with plain link: google.com</code>
-              </p>
-              <p>
-                <code>inline code with config link: LinkRewriteMe</code>
-              </p>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>
+                  <code>inline code</code>
+                </p>
+                <p>
+                  <code>inline code with plain link: google.com</code>
+                </p>
+                <p>
+                  <code>inline code with config link: LinkRewriteMe</code>
+                </p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -421,19 +448,21 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <pre>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <pre>
               <code>multiline code</code>
             </pre>
-              <pre>
+                <pre>
               <code>multiline code with plain link: google.com</code>
             </pre>
-              <pre>
+                <pre>
               <code>multiline code with config link: LinkRewriteMe</code>
             </pre>
-            </div>
-          </marked-element>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -445,11 +474,13 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>![img](google.com/img.png)</p>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>![img](google.com/img.png)</p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -461,13 +492,15 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                <gr-account-chip></gr-account-chip>
-              </p>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>
+                  <gr-account-chip></gr-account-chip>
+                </p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
       const accountChip = queryAndAssert<GrAccountChip>(
@@ -487,20 +520,22 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                <code>@</code>
-                <a
-                  href="mailto:someone@google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                >
-                  someone@google.com
-                </a>
-              </p>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>
+                  <code>@</code>
+                  <a
+                    href="mailto:someone@google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                  >
+                    someone@google.com
+                  </a>
+                </p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -519,43 +554,45 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                <a
-                  href="https://www.google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                  >myLink1</a
-                >
-                <br />
-                <a href="/destiny">myLink2</a>
-                <br />
-                <a href="${origin}/destiny">myLink3</a>
-                <br />
-                <a
-                  href="https://google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                  >myLink4</a
-                >
-                <br />
-                <a
-                  href="http://google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                  >myLink5</a
-                >
-                <br />
-                <a
-                  href="mailto:google@google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                  >myLink6</a
-                >
-              </p>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>
+                  <a
+                    href="https://www.google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                    >myLink1</a
+                  >
+                  <br />
+                  <a href="/destiny">myLink2</a>
+                  <br />
+                  <a href="${origin}/destiny">myLink3</a>
+                  <br />
+                  <a
+                    href="https://google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                    >myLink4</a
+                  >
+                  <br />
+                  <a
+                    href="http://google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                    >myLink5</a
+                  >
+                  <br />
+                  <a
+                    href="mailto:google@google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                    >myLink6</a
+                  >
+                </p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -569,37 +606,39 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <blockquote>
-                <p>block quote</p>
-              </blockquote>
-              <blockquote>
-                <p>
-                  block quote with plain link:
-                  <a
-                    href="http://google.com"
-                    rel="noopener noreferrer"
-                    target="_blank"
-                  >
-                    http://google.com
-                  </a>
-                </p>
-              </blockquote>
-              <blockquote>
-                <p>
-                  block quote with config link:
-                  <a
-                    href="http://google.com/LinkRewriteMe"
-                    rel="noopener noreferrer"
-                    target="_blank"
-                  >
-                    LinkRewriteMe
-                  </a>
-                </p>
-              </blockquote>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <blockquote>
+                  <p>block quote</p>
+                </blockquote>
+                <blockquote>
+                  <p>
+                    block quote with plain link:
+                    <a
+                      href="http://google.com"
+                      rel="noopener noreferrer"
+                      target="_blank"
+                    >
+                      http://google.com
+                    </a>
+                  </p>
+                </blockquote>
+                <blockquote>
+                  <p>
+                    block quote with config link:
+                    <a
+                      href="http://google.com/LinkRewriteMe"
+                      rel="noopener noreferrer"
+                      target="_blank"
+                    >
+                      LinkRewriteMe
+                    </a>
+                  </p>
+                </blockquote>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -616,30 +655,32 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>plain text ${escapedDiv}</p>
-              <p>
-                <code>inline code ${escapedDiv}</code>
-              </p>
-              <pre>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>plain text ${escapedDiv}</p>
+                <p>
+                  <code>inline code ${escapedDiv}</code>
+                </p>
+                <pre>
               <code>
                 multiline code ${escapedDiv}
               </code>
             </pre>
-              <blockquote>
-                <p>block quote ${escapedDiv}</p>
-              </blockquote>
-              <p>
-                <a
-                  href="http://google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                  >inline link ${escapedDiv}</a
-                >
-              </p>
-            </div>
-          </marked-element>
+                <blockquote>
+                  <p>block quote ${escapedDiv}</p>
+                </blockquote>
+                <p>
+                  <a
+                    href="http://google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                    >inline link ${escapedDiv}</a
+                  >
+                </p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -651,17 +692,19 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <blockquote>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
                 <blockquote>
                   <blockquote>
-                    <p>block quote</p>
+                    <blockquote>
+                      <p>block quote</p>
+                    </blockquote>
                   </blockquote>
                 </blockquote>
-              </blockquote>
-            </div>
-          </marked-element>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -680,19 +723,21 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                I think
-                <a
-                  href="http://google.com"
-                  rel="noopener noreferrer"
-                  target="_blank"
-                  >asterisks * rule</a
-                >
-              </p>
-            </div>
-          </marked-element>
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <p>
+                  I think
+                  <a
+                    href="http://google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                    >asterisks * rule</a
+                  >
+                </p>
+              </div>
+            </marked-element>
+          </gr-endpoint-decorator>
         `
       );
     });
@@ -710,6 +755,10 @@
       await checkLinking('http://www.google.com');
       await checkLinking('https://www.google.com');
       await checkLinking('https://www.google.com/');
+      // matches & part as well, even we first linkify and then htmlEscape
+      await checkLinking(
+        'https://google.com/traces/list?project=gerrit&tid=123'
+      );
     });
 
     suite('user suggest fix', () => {
@@ -723,11 +772,16 @@
         await element.updateComplete;
         assert.shadowDom.equal(
           element,
-          /* HTML */ `<marked-element>
-            <div class="markdown-html" slot="markdown-html">
-              <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
-            </div>
-          </marked-element>`
+          /* HTML */
+          `
+            <gr-endpoint-decorator name="formatted-text-endpoint">
+              <marked-element>
+                <div class="markdown-html" slot="markdown-html">
+                  <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+                </div>
+              </marked-element>
+            </gr-endpoint-decorator>
+          `
         );
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
index 5afd53b..647282f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index a1d2dcb..1396157 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -4,10 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {getBaseUrl} from '../../../utils/url-util';
-import {HttpMethod} from '../../../constants/constants';
-import {RequestPayload} from '../../../types/common';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {readJSONResponsePayload} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
@@ -56,33 +52,3 @@
   // TODO(taoalpha): guard with a regex
   return pathname.split('/')[2].split('.')[0];
 }
-
-export function send(
-  restApiService: RestApiService,
-  method: HttpMethod,
-  url: string,
-  callback?: (response: unknown) => void,
-  payload?: RequestPayload
-) {
-  return restApiService
-    .send(method, url, payload)
-    .then(response => {
-      if (response.status < 200 || response.status >= 300) {
-        return response.text().then((text: string | undefined) => {
-          if (text) {
-            return Promise.reject(new Error(text));
-          } else {
-            return Promise.reject(new Error(`${response.status}`));
-          }
-        });
-      } else {
-        return readJSONResponsePayload(response).then(obj => obj.parsed);
-      }
-    })
-    .then(response => {
-      if (callback) {
-        callback(response);
-      }
-      return response;
-    });
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index d42dc7c..b2e412c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../../change/gr-change-actions/gr-change-actions';
 import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 6b9c684..b09502a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -57,11 +57,7 @@
       try {
         return callback(change, revision) === false;
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('canSubmitChange callback error'),
-          err
-        );
+        this.reportError(err, EventType.SUBMIT_CHANGE);
       }
       return false;
     });
@@ -116,11 +112,18 @@
       try {
         cb(change, revision, info, baseRevision ?? PARENT);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('showChange callback error'),
-          err
-        );
+        this.reportError(err, EventType.SHOW_CHANGE);
+      }
+    }
+  }
+
+  async handleReplySent() {
+    await this.waitForPluginsToLoad();
+    for (const cb of this._getEventCallbacks(EventType.REPLY_SENT)) {
+      try {
+        cb();
+      } catch (err: unknown) {
+        this.reportError(err, EventType.REPLY_SENT);
       }
     }
   }
@@ -134,11 +137,7 @@
       try {
         cb(detail.revisionActions, detail.change);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('showRevisionActions callback error'),
-          err
-        );
+        this.reportError(err, EventType.SHOW_REVISION_ACTIONS);
       }
     }
   }
@@ -148,11 +147,7 @@
       try {
         cb(change, msg);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('commitMessage callback error'),
-          err
-        );
+        this.reportError(err, EventType.COMMIT_MSG_EDIT);
       }
     }
   }
@@ -163,11 +158,7 @@
       try {
         cb(detail.change);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('labelChange callback error'),
-          err
-        );
+        this.reportError(err, EventType.LABEL_CHANGE);
       }
     }
   }
@@ -177,11 +168,7 @@
       try {
         revertMsg = cb(change, revertMsg, origMsg) as string;
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('modifyRevertMsg callback error'),
-          err
-        );
+        this.reportError(err, EventType.REVERT);
       }
     }
     return revertMsg;
@@ -200,11 +187,7 @@
           origMsg
         ) as string;
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('modifyRevertSubmissionMsg callback error'),
-          err
-        );
+        this.reportError(err, EventType.REVERT_SUBMISSION);
       }
     }
     return revertSubmissionMsg;
@@ -230,11 +213,7 @@
           review = {labels: r as LabelNameToValueMap};
         }
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('getReviewPostRevert callback error'),
-          err
-        );
+        this.reportError(err, EventType.POST_REVERT);
       }
     }
     return review;
@@ -246,15 +225,19 @@
       try {
         cb(detail.change, detail.patchRange, detail.fileRange);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('showDiff callback error'),
-          err
-        );
+        this.reportError(err, EventType.SHOW_DIFF);
       }
     }
   }
 
+  reportError(err: unknown, type: EventType) {
+    this.reporting.error(
+      'GrJsApiInterface',
+      new Error(`plugin event callback error for type "${type}"`),
+      err
+    );
+  }
+
   _getEventCallbacks(type: EventType) {
     return eventCallbacks[type] || [];
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
index a21ddc3..d57c4f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
@@ -13,7 +13,6 @@
   stubBaseUrl,
   waitEventLoop,
   waitUntilCalled,
-  assertFails,
 } from '../../../test/test-utils';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../../test/common-test-setup';
@@ -23,7 +22,6 @@
 import {Plugin} from './gr-public-js-api';
 import {
   ChangeInfo,
-  HttpMethod,
   NumericChangeId,
   PatchSetNum,
   RevisionPatchSetNum,
@@ -43,7 +41,6 @@
   let errorStub: SinonStub;
   let pluginLoader: PluginLoader;
 
-  let sendStub: SinonStub;
   let clock: SinonFakeTimers;
 
   const throwErrFn = function () {
@@ -57,9 +54,6 @@
       name: 'Judy Hopps',
       registered_on: '' as Timestamp,
     });
-    sendStub = stubRestApi('send').resolves(
-      new Response(undefined, {status: 200})
-    );
     pluginLoader = testResolver(pluginLoaderToken);
 
     // We are using the jsApiService as the implementation class rather than the
@@ -91,28 +85,6 @@
     );
   });
 
-  test('_send on failure rejects with response text', async () => {
-    sendStub.resolves({
-      status: 400,
-      text() {
-        return Promise.resolve('text');
-      },
-    });
-    const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
-    assert.equal(error.message, 'text');
-  });
-
-  test('_send on failure without text rejects with code', async () => {
-    sendStub.resolves({
-      status: 400,
-      text() {
-        return Promise.resolve(null);
-      },
-    });
-    const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
-    assert.equal(error.message, '400');
-  });
-
   test('showchange event', async () => {
     const showChangeStub = stub();
     const testChange: ParsedChangeInfo = {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 6c180d7..dafa434 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -45,7 +45,7 @@
     revertSubmissionMsg: string,
     origMsg: string
   ): string;
-  handleShowChange(detail: ShowChangeDetail): void;
+  handleShowChange(detail: ShowChangeDetail): Promise<void>;
   handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
   handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
   modifyRevertMsg(
@@ -59,4 +59,5 @@
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
   getReviewPostRevert(change?: ChangeInfo): ReviewInput;
   handleShowDiff(detail: ShowDiffDetail): void;
+  handleReplySent(): Promise<void>;
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index ddba546..329d363 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-js-api-interface';
 import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
index b2ac2bf..3b07ddd 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
 import {PluginLoader} from './gr-plugin-loader';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index e9c132a..d27bd2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -9,22 +9,16 @@
 import {PluginApi} from '../../../api/plugin';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {readJSONResponsePayload} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {
+  readJSONResponsePayload,
+  throwingErrorCallback,
+} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 async function getErrorMessage(response: Response): Promise<string> {
   const text = await response.text();
   return text || `${response.status}`;
 }
 
-// This is an internal error, that must never be visible outside of this
-// file. It is used only inside GrPluginRestApi.send method. See detailed
-// explanation in the GrPluginRestApi.send method.
-class ResponseError extends Error {
-  public constructor(readonly response: Response) {
-    super();
-  }
-}
-
 export class GrPluginRestApi implements RestPluginApi {
   constructor(
     private readonly restApi: RestApiService,
@@ -120,53 +114,31 @@
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'send');
-    // Plugins typically don't want Gerrit to show error dialogs for failed
-    // requests. So we are defining a default errFn here, even if it is not
-    // explicitly set by the caller.
-    // TODO: We are soon getting rid of the `errFn` altogether. There are only
-    // 2 known usages of errFn in plugins: delete-project and verify-status.
-    errFn =
-      errFn ??
-      ((response: Response | null | undefined, error?: Error) => {
-        if (error) throw error;
-        // Some plugins show an error message if send is failed, smth like:
-        // pluginApi.send(...).catch(err => showError(err));
-        // The response can contain an error text, but getting this text is
-        // an asynchronous operation. At the same time, the errFn must be a
-        // synchronous function.
-        // As a workaround, we throw an ResponseError here and then catch
-        // it inside a catch block below and read the message.
-        if (response) throw new ResponseError(response);
-        throw new Error('Generic REST API error.');
-      });
-    return this.fetch(method, url, payload, errFn, contentType)
-      .then(response => {
-        // Will typically not happen. The response can only be unset, if the
-        // errFn handles the error and then returns void or undefined or null.
-        // But the errFn above always throws.
-        if (!response) {
-          throw new Error('plugin rest-api call failed');
-        }
-        // Will typically not happen. errFn will have dealt with that and the
-        // caller will get a rejected promise already.
-        if (response.status < 200 || response.status >= 300) {
-          return getErrorMessage(response).then(msg =>
-            Promise.reject(new Error(msg))
-          );
-        } else {
-          return readJSONResponsePayload(response).then(
-            obj => obj.parsed
-          ) as Promise<T>;
-        }
-      })
-      .catch(err => {
-        if (err instanceof ResponseError) {
-          return getErrorMessage(err.response).then(msg => {
-            throw new Error(msg);
-          });
-        }
-        throw err;
-      });
+    return this.fetch(
+      method,
+      url,
+      payload,
+      errFn ?? throwingErrorCallback,
+      contentType
+    ).then(response => {
+      // Will typically not happen. The response can only be unset, if the
+      // errFn handles the error and then returns void or undefined or null.
+      // But the errFn above always throws.
+      if (!response) {
+        throw new Error('plugin rest-api call failed');
+      }
+      // Will typically not happen. errFn will have dealt with that and the
+      // caller will get a rejected promise already.
+      if (response.status < 200 || response.status >= 300) {
+        return getErrorMessage(response).then(msg =>
+          Promise.reject(new Error(msg))
+        );
+      } else {
+        return readJSONResponsePayload(response).then(
+          obj => obj.parsed
+        ) as Promise<T>;
+      }
+    });
   }
 
   get<T>(url: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
index 472093e..8b5ce6a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-js-api-interface';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index d3dea78..b076373 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -14,11 +14,9 @@
 import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
 import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
-import {getPluginNameFromUrl, send} from './gr-api-utils';
+import {getPluginNameFromUrl} from './gr-api-utils';
 import {GrReportingJsApi} from './gr-reporting-js-api';
 import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
-import {RequestPayload} from '../../../types/common';
-import {HttpMethod} from '../../../constants/constants';
 import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
 import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
 import {AdminPluginApi} from '../../../api/admin';
@@ -180,15 +178,6 @@
     return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
   }
 
-  _send(
-    method: HttpMethod,
-    url: string,
-    callback?: SendCallback,
-    payload?: RequestPayload
-  ) {
-    return send(this.restApiService, method, this.url(url), callback, payload);
-  }
-
   annotationApi(): AnnotationPluginApi {
     return new GrAnnotationActionsInterface(
       this.report,
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
index 8e4edd6..2758d21 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../../change/gr-reply-dialog/gr-reply-dialog';
 import {getAppContext} from '../../../services/app-context';
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index dad056b..6345754 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-label-info';
 import {
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
index a8f6ff2..1a2d23c 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-labeled-autocomplete';
 import {GrLabeledAutocomplete} from './gr-labeled-autocomplete';
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
index 7e353f6..112ec78 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
 import {waitEventLoop} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
index 08572b6..3f9a32b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-linked-chip';
 import {GrLinkedChip} from './gr-linked-chip';
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
index 5b1e162..c0d9755 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-list-view';
 import {GrListView} from './gr-list-view';
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
index 1cdb5c7..f627577 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-page-nav';
 import {GrPageNav} from './gr-page-nav';
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
index 6839431..24b377c 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-repo-branch-picker';
 import {GrRepoBranchPicker} from './gr-repo-branch-picker';
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 85f2c06..310c360 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -45,6 +45,13 @@
   return JSON.parse(jsonWithPrefix.substring(JSON_PREFIX.length)) as ParsedJSON;
 }
 
+// Adds base url if not added in cache key
+// or doesn't add it if it already is there.
+function addBaseUrl(key: string) {
+  if (!getBaseUrl()) return key;
+  return key.startsWith(getBaseUrl()) ? key : getBaseUrl() + key;
+}
+
 /**
  * Wrapper around Map for caching server responses. Site-based so that
  * changes to CANONICAL_PATH will result in a different cache going into
@@ -66,44 +73,43 @@
       // TODO(kamilm): This implies very strict format of what is stored in
       //   INITIAL_DATA which is not clear from the name, consider renaming.
       Object.entries(window.INITIAL_DATA).forEach(e =>
-        this._cache().set(e[0], e[1] as unknown as ParsedJSON)
+        this._cache().set(addBaseUrl(e[0]), e[1] as unknown as ParsedJSON)
       );
     }
   }
 
   // Returns the cache for the current canonical path.
   _cache(): Map<string, ParsedJSON> {
-    const canonical_path = window.CANONICAL_PATH ?? '';
-    if (!this.data.has(canonical_path)) {
-      this.data.set(canonical_path, new Map<string, ParsedJSON>());
+    if (!this.data.has(getBaseUrl())) {
+      this.data.set(getBaseUrl(), new Map<string, ParsedJSON>());
     }
-    return this.data.get(canonical_path)!;
+    return this.data.get(getBaseUrl())!;
   }
 
   has(key: string) {
-    return this._cache().has(key);
+    return this._cache().has(addBaseUrl(key));
   }
 
   get(key: string): ParsedJSON | undefined {
-    return this._cache().get(key);
+    return this._cache().get(addBaseUrl(key));
   }
 
   set(key: string, value: ParsedJSON) {
-    this._cache().set(key, value);
+    this._cache().set(addBaseUrl(key), value);
   }
 
   delete(key: string) {
-    this._cache().delete(key);
+    this._cache().delete(addBaseUrl(key));
   }
 
   invalidatePrefix(prefix: string) {
     const newMap = new Map<string, ParsedJSON>();
     for (const [key, value] of this._cache().entries()) {
-      if (!key.startsWith(prefix)) {
+      if (!key.startsWith(addBaseUrl(prefix))) {
         newMap.set(key, value);
       }
     }
-    this.data.set(window.CANONICAL_PATH ?? '', newMap);
+    this.data.set(getBaseUrl(), newMap);
   }
 }
 
@@ -129,11 +135,11 @@
    * @return true only if a value for a key sets and it is not undefined
    */
   has(key: string): boolean {
-    return !!this.data[key];
+    return !!this.data[addBaseUrl(key)];
   }
 
   get(key: string) {
-    return this.data[key];
+    return this.data[addBaseUrl(key)];
   }
 
   /**
@@ -141,13 +147,13 @@
    *     mark key as deleted.
    */
   set(key: string, value: Promise<ParsedJSON | undefined> | undefined) {
-    this.data[key] = value;
+    this.data[addBaseUrl(key)] = value;
   }
 
   invalidatePrefix(prefix: string) {
     const newData: FetchPromisesCacheData = {};
     Object.entries(this.data).forEach(([key, value]) => {
-      if (!key.startsWith(prefix)) {
+      if (!key.startsWith(addBaseUrl(prefix))) {
         newData[key] = value;
       }
     });
@@ -349,12 +355,17 @@
     try {
       resp = await this.fetchImpl(fetchReq);
     } catch (err) {
+      // Wrap the error to get more information about the stack.
+      const newErr = new Error(
+        `Network error when trying to fetch. Cause: ${(err as Error).message}`
+      );
+      newErr.stack = (newErr.stack ?? '') + '\n' + ((err as Error).stack ?? '');
       if (req.errFn) {
-        await req.errFn.call(undefined, null, err as Error);
+        await req.errFn.call(undefined, null, newErr);
       } else {
-        fireNetworkError(err as Error);
+        fireNetworkError(newErr);
       }
-      throw err;
+      throw newErr;
     }
     if (req.reportServerError && !resp.ok) {
       if (req.errFn) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 0f94a4b..cea6704 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2024 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../../test/common-test-setup';
 import {
   SiteBasedCache,
@@ -206,7 +207,10 @@
         const promise = helper.fetchJSON({url: '/dummy/url'});
         await assertReadRequest();
         const err = await assertFails(promise);
-        assert.equal((err as Error).message, 'No response');
+        assert.equal(
+          (err as Error).message,
+          'Network error when trying to fetch. Cause: No response'
+        );
         await waitEventLoop();
         assert.isTrue(networkErrorCalled);
         assert.isFalse(serverErrorCalled);
@@ -221,7 +225,10 @@
         });
         await assertReadRequest();
         const err = await assertFails(promise);
-        assert.equal((err as Error).message, 'No response');
+        assert.equal(
+          (err as Error).message,
+          'Network error when trying to fetch. Cause: No response'
+        );
         await waitEventLoop();
         assert.isTrue(errFn.called);
         assert.isFalse(networkErrorCalled);
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
index 4bb63ea..20f708e 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-select';
 import {fixture, html, assert} from '@open-wc/testing';
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index f489664..b2fed78 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2018 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-shell-command';
 import {GrShellCommand} from './gr-shell-command';
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 497ed2b..93f17c8 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -7,7 +7,12 @@
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {getAppContext} from '../../../services/app-context';
-import {Comment, EDIT, BasePatchSetNum, RepoName} from '../../../types/common';
+import {
+  EDIT,
+  BasePatchSetNum,
+  PatchSetNumber,
+  RepoName,
+} from '../../../types/common';
 import {anyLineTooLong} from '../../../utils/diff-util';
 import {
   DiffLayer,
@@ -19,73 +24,81 @@
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {resolve} from '../../../models/dependency';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
-import {FixReplacementInfo, NumericChangeId} from '../../../api/rest-api';
+import {FixSuggestionInfo, NumericChangeId} from '../../../api/rest-api';
 import {changeModelToken} from '../../../models/change/change-model';
 import {subscribe} from '../../lit/subscription-controller';
-import {FilePreview} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {DiffPreview} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {userModelToken} from '../../../models/user/user-model';
-import {createUserFixSuggestion} from '../../../utils/comment-util';
-import {commentModelToken} from '../gr-comment-model/gr-comment-model';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {fire} from '../../../utils/event-util';
-import {Interaction, Timing} from '../../../constants/reporting';
+import {Timing} from '../../../constants/reporting';
 import {createChangeUrl} from '../../../models/views/change';
+import {getFileExtension} from '../../../utils/file-util';
 
-declare global {
-  interface HTMLElementEventMap {
-    'add-generated-suggestion': AddGeneratedSuggestionEvent;
-  }
+export interface PreviewLoadedDetail {
+  previewLoadedFor?: FixSuggestionInfo;
 }
-
-export type AddGeneratedSuggestionEvent =
-  CustomEvent<OpenUserSuggestionPreviewEventDetail>;
-export interface OpenUserSuggestionPreviewEventDetail {
-  code: string;
-}
-
 /**
  * Diff preview for
- * 1. suggestion vs commented Text
- * or 2. fixReplacementInfos
- * that are attached to a comment.
+ * 1. code block suggestion vs commented Text
+ * or 2. fixSuggestionInfo that are attached to a comment.
+ *
+ * It shouldn't be created with both 1. and 2. but if it is
+ * it shows just for 1. (code block suggestion)
  */
 @customElement('gr-suggestion-diff-preview')
 export class GrSuggestionDiffPreview extends LitElement {
+  // Optional. Used as backup when preview is not loaded.
   @property({type: String})
-  suggestion?: string;
+  codeText?: string;
 
+  // Required.
   @property({type: Object})
-  fixReplacementInfos?: FixReplacementInfo[];
+  fixSuggestionInfo?: FixSuggestionInfo;
 
-  @property({type: Boolean})
-  showAddSuggestionButton = false;
+  // Used to determine if the preview has been loaded
+  // this is identical to previewLoadedFor !== undefined and can be removed
+  @property({type: Boolean, attribute: 'previewed', reflect: true})
+  previewed = false;
 
+  // Optional. Used in logging.
   @property({type: String})
   uuid?: string;
 
-  @state()
-  comment?: Comment;
+  @property({type: Number})
+  patchSet?: BasePatchSetNum;
 
-  @state()
-  commentedText?: string;
+  // Optional. Used in logging.
+  @property({type: String})
+  commentId?: string;
 
   @state()
   layers: DiffLayer[] = [];
 
+  /**
+   * The fix suggestion info that the preview is loaded for.
+   *
+   * This is used to determine if the preview has been loaded for the same
+   * fix suggestion info currently in gr-comment.
+   */
   @state()
-  previewLoadedFor?: string | FixReplacementInfo[];
+  public previewLoadedFor?: FixSuggestionInfo;
 
   @state() repo?: RepoName;
 
+  @state() hasEdit = false;
+
   @state()
   changeNum?: NumericChangeId;
 
   @state()
-  preview?: FilePreview;
+  preview?: DiffPreview;
 
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
+  @state() latestPatchNum?: PatchSetNumber;
+
   @state()
   renderPrefs: RenderPreferences = {
     disable_context_control_buttons: true,
@@ -101,8 +114,6 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly getCommentModel = resolve(this, commentModelToken);
-
   private readonly getNavigation = resolve(this, navigationToken);
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -119,6 +130,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
       () => this.getUserModel().diffPreferences$,
       diffPreferences => {
         if (!diffPreferences) return;
@@ -128,16 +144,6 @@
     );
     subscribe(
       this,
-      () => this.getCommentModel().comment$,
-      comment => (this.comment = comment)
-    );
-    subscribe(
-      this,
-      () => this.getCommentModel().commentedText$,
-      commentedText => (this.commentedText = commentedText)
-    );
-    subscribe(
-      this,
       () => this.getChangeModel().repo$,
       x => (this.repo = x)
     );
@@ -146,9 +152,16 @@
   static override get styles() {
     return [
       css`
+        :host {
+          display: block;
+        }
         .buttons {
           text-align: right;
         }
+        .diff-container {
+          border: 1px solid var(--border-color);
+          border-top: none;
+        }
         code {
           max-width: var(--gr-formatted-text-prose-max-width, none);
           background-color: var(--background-color-secondary);
@@ -171,40 +184,22 @@
   }
 
   override updated(changed: PropertyValues) {
-    if (changed.has('commentedText') || changed.has('comment')) {
-      if (this.previewLoadedFor !== this.suggestion) {
-        this.fetchFixPreview();
-      }
-    }
-
-    if (changed.has('changeNum') || changed.has('comment')) {
-      if (this.previewLoadedFor !== this.fixReplacementInfos) {
-        this.fetchFixReplacementInfosPreview();
-      }
+    if (
+      changed.has('fixSuggestionInfo') ||
+      changed.has('changeNum') ||
+      changed.has('patchSet')
+    ) {
+      this.fetchFixPreview();
     }
   }
 
   override render() {
-    if (!this.suggestion && !this.fixReplacementInfos) return nothing;
-    const code = this.suggestion;
+    if (!this.fixSuggestionInfo) return nothing;
     return html`
       ${when(
         this.previewLoadedFor,
         () => this.renderDiff(),
-        () => html`<code>${code}</code>`
-      )}
-      ${when(
-        this.showAddSuggestionButton,
-        () =>
-          html`<div class="buttons">
-            <gr-button
-              link
-              class="action add-suggestion"
-              @click=${this.handleAddGeneratedSuggestion}
-            >
-              Add suggestion to comment
-            </gr-button>
-          </div>`
+        () => html`<code>${this.codeText}</code>`
       )}
     `;
   }
@@ -215,34 +210,26 @@
     if (!anyLineTooLong(diff)) {
       this.syntaxLayer.process(diff);
     }
-    return html`<gr-diff
-      .prefs=${this.overridePartialDiffPrefs()}
-      .path=${this.preview.filepath}
-      .diff=${diff}
-      .layers=${this.layers}
-      .renderPrefs=${this.renderPrefs}
-      .viewMode=${DiffViewMode.UNIFIED}
-    ></gr-diff>`;
+    return html`<div class="diff-container">
+      <gr-diff
+        .prefs=${this.overridePartialDiffPrefs()}
+        .path=${this.preview.filepath}
+        .diff=${diff}
+        .layers=${this.layers}
+        .renderPrefs=${this.renderPrefs}
+        .viewMode=${DiffViewMode.UNIFIED}
+      ></gr-diff>
+    </div>`;
   }
 
   private async fetchFixPreview() {
-    if (
-      !this.changeNum ||
-      !this.comment?.patch_set ||
-      !this.suggestion ||
-      !this.commentedText
-    )
-      return;
-    const fixSuggestions = createUserFixSuggestion(
-      this.comment,
-      this.commentedText,
-      this.suggestion
-    );
+    if (!this.changeNum || !this.patchSet || !this.fixSuggestionInfo) return;
+
     this.reporting.time(Timing.PREVIEW_FIX_LOAD);
     const res = await this.restApiService.getFixPreview(
       this.changeNum,
-      this.comment?.patch_set,
-      fixSuggestions[0].replacements
+      this.patchSet,
+      this.fixSuggestionInfo.replacements
     );
     if (!res) return;
     const currentPreviews = Object.keys(res).map(key => {
@@ -250,86 +237,49 @@
     });
     this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
       uuid: this.uuid,
+      commentId: this.commentId ?? '',
     });
     if (currentPreviews.length > 0) {
       this.preview = currentPreviews[0];
-      this.previewLoadedFor = this.suggestion;
+      this.previewLoadedFor = this.fixSuggestionInfo;
+      this.previewed = true;
+
+      fire(this, 'preview-loaded', {
+        previewLoadedFor: this.fixSuggestionInfo,
+      });
     }
 
     return res;
   }
-
-  private async fetchFixReplacementInfosPreview() {
-    if (
-      this.suggestion ||
-      !this.changeNum ||
-      !this.comment?.patch_set ||
-      !this.fixReplacementInfos
-    )
-      return;
-
-    // TODO (milutin): This is a temporary fix for the broken path issue.
-    // Our experimental plugin currently returns only the file extension.
-    const replacements = this.fixReplacementInfos.map(fixInfo => {
-      return {
-        ...fixInfo,
-        path: this.comment?.path ?? fixInfo.path,
-      };
-    });
-
-    this.reporting.time(Timing.PREVIEW_FIX_LOAD);
-    const res = await this.restApiService.getFixPreview(
-      this.changeNum,
-      this.comment?.patch_set,
-      replacements
-    );
-
-    if (!res) return;
-    const currentPreviews = Object.keys(res).map(key => {
-      return {filepath: key, preview: res[key]};
-    });
-    this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
-      uuid: this.uuid,
-    });
-    if (currentPreviews.length > 0) {
-      this.preview = currentPreviews[0];
-      this.previewLoadedFor = this.fixReplacementInfos;
-    }
-
-    return res;
-  }
-
   /**
-   * Applies a fix previewed in `suggestion-diff-preview`,
-   * navigating to the new change URL with the EDIT patchset.
+   * Applies a fix (codeblock in comment message) previewed in
+   * `suggestion-diff-preview`, navigating to the new change URL with the EDIT
+   * patchset.
    *
    * Similar code flow is in gr-apply-fix-dialog.handleApplyFix
    * Used in gr-user-suggestion-fix
    */
+
   public async applyFix() {
-    if (
-      !this.changeNum ||
-      !this.comment?.patch_set ||
-      !this.suggestion ||
-      !this.commentedText
-    )
-      return;
     const changeNum = this.changeNum;
-    const basePatchNum = this.comment?.patch_set as BasePatchSetNum;
-    const fixSuggestions = createUserFixSuggestion(
-      this.comment,
-      this.commentedText,
-      this.suggestion
-    );
+    const basePatchNum = this.patchSet;
+    const fixSuggestion = this.fixSuggestionInfo;
+    if (!changeNum || !basePatchNum || !fixSuggestion) return;
+
     this.reporting.time(Timing.APPLY_FIX_LOAD);
     const res = await this.restApiService.applyFixSuggestion(
-      this.changeNum,
-      this.comment?.patch_set,
-      fixSuggestions[0].replacements
+      changeNum,
+      basePatchNum,
+      fixSuggestion.replacements,
+      this.latestPatchNum
     );
     this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
       method: '1-click',
-      description: fixSuggestions?.[0].description,
+      description: fixSuggestion.description,
+      fileExtension: getFileExtension(
+        fixSuggestion?.replacements?.[0].path ?? ''
+      ),
+      commentId: this.commentId ?? '',
     });
     if (res?.ok) {
       this.getNavigation().setUrl(
@@ -338,8 +288,10 @@
           repo: this.repo!,
           patchNum: EDIT,
           basePatchNum,
+          forceReload: !this.hasEdit,
         })
       );
+      fire(this, 'reload-diff', {path: fixSuggestion.replacements[0].path});
       fire(this, 'apply-user-suggestion', {});
     }
   }
@@ -353,18 +305,13 @@
       line_wrapping: true,
     };
   }
-
-  handleAddGeneratedSuggestion() {
-    if (!this.suggestion) return;
-    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_ADDED, {
-      uuid: this.uuid,
-    });
-    fire(this, 'add-generated-suggestion', {code: this.suggestion});
-  }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-suggestion-diff-preview': GrSuggestionDiffPreview;
   }
+  interface HTMLElementEventMap {
+    'preview-loaded': CustomEvent<PreviewLoadedDetail>;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
index 86be868..593d0a6 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -11,7 +11,10 @@
   commentModelToken,
 } from '../gr-comment-model/gr-comment-model';
 import {wrapInProvider} from '../../../models/di-provider-element';
-import {createComment} from '../../../test/test-data-generators';
+import {
+  createComment,
+  createFixSuggestionInfo,
+} from '../../../test/test-data-generators';
 import {getAppContext} from '../../../services/app-context';
 import {GrSuggestionDiffPreview} from './gr-suggestion-diff-preview';
 import {stubFlags} from '../../../test/test-utils';
@@ -29,7 +32,8 @@
         wrapInProvider(
           html`
             <gr-suggestion-diff-preview
-              .suggestion=${'Hello World'}
+              .codeText=${'Hello World'}
+              .fixSuggestionInfo=${createFixSuggestionInfo()}
             ></gr-suggestion-diff-preview>
           `,
           commentModelToken,
@@ -48,9 +52,8 @@
 
   test('render diff', async () => {
     stubFlags('isEnabled').returns(true);
-    element.suggestion =
-      '  private handleClick(e: MouseEvent) {\ne.stopPropagation();\ne.preventDefault();';
-    element.previewLoadedFor =
+    element.previewLoadedFor = createFixSuggestionInfo();
+    element.codeText =
       '  private handleClick(e: MouseEvent) {\ne.stopPropagation();\ne.preventDefault();';
     element.preview = {
       filepath:
@@ -103,10 +106,12 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <gr-diff
-          class="disable-context-control-buttons hide-line-length-indicator"
-        >
-        </gr-diff>
+        <div class="diff-container">
+          <gr-diff
+            class="disable-context-control-buttons hide-line-length-indicator"
+          >
+          </gr-diff>
+        </div>
       `,
       {ignoreAttributes: ['style']}
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
similarity index 86%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 7f70911..d0f6917 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -7,15 +7,14 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
+import '../../../embed/gr-textarea';
 import {getAppContext} from '../../../services/app-context';
-import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {
   GrAutocompleteDropdown,
   Item,
   ItemSelectedEventDetail,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {Key} from '../../../utils/dom-util';
-import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -31,6 +30,7 @@
 import {getAccountDisplayName} from '../../../utils/display-name-util';
 import {configModelToken} from '../../../models/config/config-model';
 import {formStyles} from '../../../styles/form-styles';
+import {GrTextarea} from '../../../embed/gr-textarea';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -72,12 +72,12 @@
   }
 }
 
-@customElement('gr-textarea')
-export class GrTextarea extends LitElement {
+@customElement('gr-suggestion-textarea')
+export class GrSuggestionTextarea extends LitElement {
   /**
    * @event bind-value-changed
    */
-  @query('#textarea') textarea?: IronAutogrowTextareaElement;
+  @query('#textarea') textarea?: GrTextarea;
 
   @query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
 
@@ -108,6 +108,12 @@
     standard monospace font. */
   @property({type: Boolean}) code = false;
 
+  /**
+   * An autocompletion hint that is passed to <gr-textarea>, which will allow\
+   * the user to accept it by pressing tab.
+   */
+  @property({type: String}) autocompleteHint = '';
+
   @state() suggestions: (Item | EmojiSuggestion)[] = [];
 
   // Accessed in tests.
@@ -170,6 +176,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
+
     if (this.monospace) {
       this.classList.add('monospace');
     }
@@ -206,14 +213,27 @@
         #textarea {
           background-color: var(--view-background-color);
           width: 100%;
+          color: var(--primary-text-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: 0;
+          box-sizing: border-box;
+          position: relative;
+          --gr-textarea-padding: var(--spacing-s);
+          --gr-textarea-border-width: 0px;
+          --gr-textarea-border-color: var(--border-color);
+          --input-field-bg: var(--view-background-color);
+          --input-field-disabled-bg: var(--view-background-color);
+          --secondary-bg-color: var(--background-color-secondary);
+          --text-default: var(--primary-text-color);
+          --text-disabled: var(--deemphasized-text-color);
+          --text-secondary: var(--deemphasized-text-color);
+          --iron-autogrow-textarea_-_padding: var(--spacing-s);
         }
         #hiddenText #emojiSuggestions {
           visibility: visible;
           white-space: normal;
         }
-        iron-autogrow-textarea {
-          position: relative;
-        }
         #textarea.noBorder {
           border: none;
         }
@@ -237,22 +257,26 @@
       it is set as the positionTarget for the emojiSuggestions dropdown. -->
       <span id="caratSpan"></span>
       ${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
-      <iron-autogrow-textarea
-        id="textarea"
-        class=${classMap({noBorder: this.hideBorder})}
-        .autocomplete=${this.autocomplete}
-        .placeholder=${this.placeholder}
-        ?disabled=${this.disabled}
-        .rows=${this.rows}
-        .maxRows=${this.maxRows}
-        .value=${this.text}
-        @value-changed=${(e: ValueChangedEvent) => {
-          this.text = e.detail.value;
-        }}
-      ></iron-autogrow-textarea>
+      ${this.renderTextarea()}
     `;
   }
 
+  private renderTextarea() {
+    return html`<gr-textarea
+      id="textarea"
+      putCursorAtEndOnFocus
+      class=${classMap({noBorder: this.hideBorder})}
+      .placeholder=${this.placeholder}
+      ?disabled=${this.disabled}
+      .value=${this.text}
+      .hint=${this.autocompleteHint}
+      @input=${(e: InputEvent) => {
+        const value = (e.target as GrTextarea).value;
+        this.text = value ?? '';
+      }}
+    ></gr-textarea>`;
+  }
+
   private renderEmojiDropdown() {
     return html`
       <gr-autocomplete-dropdown
@@ -282,9 +306,6 @@
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('text')) {
       this.fireChangedEvents();
-      // Add to updated because we want this.textarea.selectionStart and
-      // this.textarea is null in the willUpdate lifecycle
-      this.computeIndexAndSearchString();
       this.handleTextChanged();
     }
   }
@@ -295,22 +316,14 @@
     this.emojiSuggestions?.close();
   }
 
-  getNativeTextarea() {
-    return this.textarea!.textarea;
-  }
-
+  // Note that this may not work as intended, because the textarea is not
+  // rendered yet.
   override focus() {
-    // Note that this may not work as intended, because the textarea is not
-    // rendered yet.
-    this.textarea?.textarea.focus();
+    this.textarea?.focus();
   }
 
   putCursorAtEnd() {
-    const textarea = this.getNativeTextarea();
-    // Put the cursor at the end always.
-    textarea.selectionStart = textarea.value.length;
-    textarea.selectionEnd = textarea.selectionStart;
-    textarea.focus();
+    this.textarea?.putCursorAtEnd();
   }
 
   private getVisibleDropdown() {
@@ -433,11 +446,14 @@
     // below needs to happen after iron-autogrow-textarea has set the
     // incorrect value.
     await this.updateComplete;
-    this.textarea!.selectionStart = specialCharIndex + text.length + move;
-    this.textarea!.selectionEnd = specialCharIndex + text.length + move;
+    this.setCursorPosition(specialCharIndex + text.length + move);
     this.resetDropdown();
   }
 
+  setCursorPosition(pos: number) {
+    this.textarea?.setCursorPosition(pos);
+  }
+
   private addValueToText(value: string) {
     if (!this.text) return '';
     const specialCharIndex = this.specialCharIndex ?? 0;
@@ -456,12 +472,11 @@
    * private but used in test
    */
   updateCaratPosition() {
-    if (typeof this.textarea!.value === 'string') {
-      this.hiddenText!.textContent = this.textarea!.value.substring(
-        0,
-        this.textarea!.selectionStart
-      );
+    let position = this.textarea?.getCursorPosition() ?? -1;
+    if (position === -1) {
+      position = this.text.length;
     }
+    this.hiddenText!.textContent = this.text.substring(0, position);
 
     const caratSpan = this.caratSpan!;
     this.hiddenText!.appendChild(caratSpan);
@@ -474,9 +489,9 @@
     // - The search string is an space or new line
     // - The colon has been removed
     // - There are no suggestions that match the search string
+    const position = this.textarea?.getCursorPosition() ?? -1;
     return (
-      this.textarea!.selectionStart !==
-        (this.currentSearchString ?? '').length + charIndex + 1 ||
+      position !== (this.currentSearchString ?? '').length + charIndex + 1 ||
       this.currentSearchString === ' ' ||
       this.currentSearchString === '\n' ||
       !(text[charIndex] === char)
@@ -522,7 +537,7 @@
       )
     ) {
       this.resetDropdown();
-    } else if (activeDropdown!.isHidden && this.textarea!.focused) {
+    } else if (activeDropdown!.isHidden && this.isTextareaFocused()) {
       // Otherwise open the dropdown and set the position to be just below the
       // cursor.
       // Do not open dropdown if textarea is not focused
@@ -543,8 +558,11 @@
     );
   }
 
-  private computeIndexAndSearchString() {
-    const currentCarat = this.textarea?.selectionStart ?? this.text.length;
+  public computeIndexAndSearchString() {
+    let currentCarat = this.textarea?.getCursorPosition() ?? -1;
+    if (currentCarat === -1) {
+      currentCarat = this.text.length;
+    }
     const m = this.text
       .substring(0, currentCarat)
       .match(/(?:^|\s)([:@][\S]*)$/);
@@ -561,6 +579,7 @@
 
   // Private but used in tests.
   async handleTextChanged() {
+    this.computeIndexAndSearchString();
     await this.computeSuggestions();
     this.openOrResetDropdown();
     this.focus();
@@ -643,10 +662,8 @@
     // When nothing is selected, selectionStart is the caret position. We want
     // the indentation level of the current line, not the end of the text which
     // may be different.
-    const currentLine = this.textarea!.textarea.value.substring(
-      0,
-      this.textarea!.selectionStart
-    )
+    const currentLine = this.text
+      .substring(0, this.textarea?.getCursorPosition() ?? -1)
       .split('\n')
       .pop();
     const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
@@ -665,10 +682,14 @@
     // queue.
     document.execCommand('insertText', false, '\n' + currentLineIndentation);
   }
+
+  isTextareaFocused() {
+    return !!this.textarea?.isFocused;
+  }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-textarea': GrTextarea;
+    'gr-suggestion-textarea': GrSuggestionTextarea;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
index d84f5a7..a6a1d83 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
@@ -3,9 +3,10 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
-import './gr-textarea';
-import {GrTextarea} from './gr-textarea';
+import './gr-suggestion-textarea';
+import {GrSuggestionTextarea} from './gr-suggestion-textarea';
 import {
   Item,
   ItemSelectedEventDetail,
@@ -20,11 +21,22 @@
 import {createAccountWithEmail} from '../../../test/test-data-generators';
 import {Key} from '../../../utils/dom-util';
 
-suite('gr-textarea tests', () => {
-  let element: GrTextarea;
+suite('gr-suggestion-textarea tests with <gr-textarea>', () => {
+  let element: GrSuggestionTextarea;
+
+  const setText = async (text: string) => {
+    element.text = text;
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.setCursorPosition(text.length);
+    element.handleTextChanged();
+    await element.updateComplete;
+  };
 
   setup(async () => {
-    element = await fixture<GrTextarea>(html`<gr-textarea></gr-textarea>`);
+    element = await fixture<GrSuggestionTextarea>(
+      html`<gr-suggestion-textarea></gr-suggestion-textarea>`
+    );
     sinon.stub(element.reporting, 'reportInteraction');
     await element.updateComplete;
   });
@@ -42,8 +54,7 @@
           role="listbox"
         >
         </gr-autocomplete-dropdown>
-        <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
-        </iron-autogrow-textarea>`,
+        <gr-textarea putcursoratendonfocus id="textarea"> </gr-textarea>`,
       {
         // gr-autocomplete-dropdown sizing seems to vary between local & CI
         ignoreAttributes: [
@@ -66,17 +77,14 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
-
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await waitUntil(() => element.isTextareaFocused() === true);
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
 
       assert.equal(listenerStub.lastCall.args[0].detail.value, '@');
-      assert.isTrue(element.textarea!.focused);
+      assert.isTrue(element.isTextareaFocused());
 
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
@@ -85,8 +93,7 @@
       assert.isFalse(element.mentionsSuggestions!.isHidden);
       assert.equal(element.currentSearchString, '');
 
-      element.text = '@abc@google.com';
-      await element.updateComplete;
+      await setText('@abc@google.com');
 
       assert.equal(element.currentSearchString, 'abc@google.com');
       assert.equal(element.specialCharIndex, 0);
@@ -104,11 +111,9 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.isTextareaFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '\n@';
+      await setText('\n@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -132,14 +137,12 @@
       const promise = mockPromise<Item[]>();
       stubRestApi('queryAccounts').returns(promise);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.isTextareaFocused() === true);
 
       element.suggestions = [
         {dataValue: 'prior@google.com', text: 'Prior suggestion'},
       ];
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await element.updateComplete;
       assert.equal(element.suggestions.length, 0);
@@ -167,16 +170,13 @@
       const suggestionStub = stubRestApi('queryAccounts');
       suggestionStub.returns(promise1);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.isTextareaFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
-      await element.updateComplete;
+      await setText('@');
       assert.equal(element.currentSearchString, '');
 
       suggestionStub.returns(promise2);
-      element.text = '@abc@google.com';
+      await setText('@abc@google.com');
       // None of suggestions returned yet.
       assert.equal(element.suggestions.length, 0);
       await element.updateComplete;
@@ -229,11 +229,9 @@
       );
 
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.isTextareaFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -253,7 +251,6 @@
     test('emoji dropdown does not open if mention dropdown is open', async () => {
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
-      const resetSpy = sinon.spy(element, 'resetDropdown');
       stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
@@ -261,11 +258,9 @@
         ])
       );
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.isTextareaFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
       element.suggestions = [
         {
           name: 'a',
@@ -275,30 +270,28 @@
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
 
-      assert.isFalse(resetSpy.called);
-
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h';
+      await setText('@h');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h';
+      await setText('@h');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h:';
+      await setText('@h:');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h:D';
+      await setText('@h:D');
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -309,11 +302,9 @@
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.isTextareaFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
+      await setText(':');
       element.suggestions = [
         {
           name: 'a',
@@ -325,23 +316,23 @@
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D';
+      await setText(':D');
       await element.updateComplete;
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@';
+      await setText(':D@');
       await element.updateComplete;
       // emoji dropdown hidden since we have no more suggestions
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@b';
+      await setText(':D@b');
       await element.updateComplete;
       assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
-      element.text = ':D@b ';
+      await setText(':D@b ');
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
@@ -356,11 +347,9 @@
       );
 
       element.textarea!.focus();
-      await waitUntil(() => element.textarea!.focused === true);
+      await waitUntil(() => element.isTextareaFocused() === true);
 
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = '@';
+      await setText('@');
 
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
@@ -385,8 +374,7 @@
     // by default textarea has focus when rendered
     // explicitly remove focus from the element for the test
     element.blur();
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
+    element.setCursorPosition(1);
     element.text = ':';
     await element.updateComplete;
     assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -394,9 +382,8 @@
 
   test('emoji selector is not open when a general text is entered', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    element.textarea!.selectionStart = 9;
-    element.textarea!.selectionEnd = 9;
+    await waitUntil(() => element.isTextareaFocused() === true);
+    element.setCursorPosition(9);
     element.text = 'some text';
     await element.updateComplete;
     assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -408,13 +395,13 @@
     const listenerStub = sinon.stub();
     element.addEventListener('text-changed', listenerStub);
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
-    element.text = ':';
-    await element.updateComplete;
+    await waitUntil(() => element.isTextareaFocused() === true);
+    await setText(':');
     assert.equal(listenerStub.lastCall.args[0].detail.value, ':');
-    assert.isTrue(element.textarea!.focused);
+    assert.isTrue(element.isTextareaFocused());
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    await element.emojiSuggestions!.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 0);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -423,13 +410,8 @@
 
   test('emoji selector opens when a colon is typed after space', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 2;
-    element.textarea!.selectionEnd = 2;
-    element.text = ' :';
-    await element.updateComplete;
+    await waitUntil(() => element.isTextareaFocused() === true);
+    await setText(' :');
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 1);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -438,30 +420,17 @@
 
   test('emoji selector doesn`t open when a colon is typed after character', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 5;
-    element.textarea!.selectionEnd = 5;
-    element.text = 'test:';
-    await element.updateComplete;
+    await waitUntil(() => element.isTextareaFocused() === true);
+    await setText('test:');
     assert.isTrue(element.emojiSuggestions!.isHidden);
     assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
   test('emoji selector opens when a colon is typed and some substring', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    // Needed for Safari tests. selectionStart is not updated when text is
-    // updated.
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
-    element.text = ':';
-    await element.updateComplete;
-    element.textarea!.selectionStart = 2;
-    element.textarea!.selectionEnd = 2;
-    element.text = ':t';
-    await element.updateComplete;
+    await waitUntil(() => element.isTextareaFocused() === true);
+    await setText(':');
+    await setText(':t');
     assert.isFalse(element.emojiSuggestions!.isHidden);
     assert.equal(element.specialCharIndex, 0);
     assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -472,19 +441,11 @@
     element.textarea!.focus();
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.textarea!.selectionStart = 1;
-    element.textarea!.selectionEnd = 1;
+    element.setCursorPosition(1);
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 1
     const text = ': hello';
-    sinon.stub(element, 'textarea').value({
-      selectionStart: 1,
-      value: text,
-      focused: true,
-      textarea: {
-        focus: () => {},
-      },
-    });
+    sinon.stub(element.textarea!, 'getCursorPosition').returns(1);
     element.text = text;
     await element.updateComplete;
     assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -495,25 +456,14 @@
 
   test('emoji selector closes when text changes before the colon', async () => {
     element.textarea!.focus();
-    await waitUntil(() => element.textarea!.focused === true);
-    await element.updateComplete;
-    element.textarea!.selectionStart = 10;
-    element.textarea!.selectionEnd = 10;
-    element.text = 'test test ';
-    await element.updateComplete;
-    element.textarea!.selectionStart = 12;
-    element.textarea!.selectionEnd = 12;
-
-    element.text = 'test test :';
-    await element.updateComplete;
+    await waitUntil(() => element.isTextareaFocused() === true);
+    await setText('test test ');
+    await setText('test test :');
 
     // typing : opens the selector
     assert.isFalse(element.emojiSuggestions!.isHidden);
 
-    element.textarea!.selectionStart = 15;
-    element.textarea!.selectionEnd = 15;
-    element.text = 'test test :smi';
-    await element.updateComplete;
+    await setText('test test :smi');
 
     assert.equal(element.currentSearchString, 'smi');
     assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -571,10 +521,12 @@
   });
 
   test('handleDropdownItemSelect', async () => {
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
+    await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.setCursorPosition(16);
     element.specialCharIndex = 10;
+    element.handleTextChanged();
     await element.updateComplete;
     const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
     const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
@@ -585,46 +537,37 @@
 
     // wait for reset dropdown to finish
     await waitUntil(() => element.specialCharIndex === -1);
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
-    element.specialCharIndex = 10;
     await element.updateComplete;
+    await element.textarea!.updateComplete;
+    element.setCursorPosition(16);
+    await element.updateComplete;
+    element.specialCharIndex = 10;
+    element.handleTextChanged();
     // move the cursor to the left while the suggestion popup is open
-    element.textarea!.selectionStart = 0;
+    element.setCursorPosition(0);
     element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂');
 
     // wait for reset dropdown to finish
     await waitUntil(() => element.specialCharIndex === -1);
-    element.textarea!.selectionStart = 16;
-    element.textarea!.selectionEnd = 16;
+    element.setCursorPosition(16);
     const text = 'test test :tears happy';
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 16
-    const stub = sinon.stub(element, 'textarea').value({
-      selectionStart: 16,
-      value: text,
-      focused: true,
-      textarea: {
-        focus: () => {},
-      },
-    });
+    const stub = sinon.stub(element.textarea!, 'getCursorPosition').returns(16);
     element.text = text;
     element.specialCharIndex = 10;
     await element.updateComplete;
     stub.restore();
     // move the cursor to the right while the suggestion popup is open
-    element.textarea!.selectionStart = 22;
+    element.setCursorPosition(22);
     element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂 happy');
   });
 
   test('updateCaratPosition', async () => {
-    element.textarea!.selectionStart = 4;
-    element.textarea!.selectionEnd = 4;
-    element.text = 'test';
-    await element.updateComplete;
+    await setText('test');
     element.updateCaratPosition();
     assert.deepEqual(
       element.hiddenText!.innerHTML,
@@ -634,7 +577,7 @@
 
   test('newline receives matching indentation', async () => {
     const indentCommand = sinon.stub(document, 'execCommand');
-    element.textarea!.value = '    a';
+    await setText('    a');
     element.handleEnterByKey(new KeyboardEvent('keydown', {key: 'Enter'}));
     await element.updateComplete;
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
@@ -653,24 +596,11 @@
   });
 
   suite('keyboard shortcuts', async () => {
-    async function setupDropdown() {
-      element.textarea!.focus();
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
-      await element.updateComplete;
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 2;
-      element.text = ':1';
-      await element.emojiSuggestions!.updateComplete;
-      await element.updateComplete;
-    }
-
     test('escape key', async () => {
       const resetSpy = sinon.spy(element, 'resetDropdown');
       pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isFalse(resetSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isTrue(resetSpy.called);
       assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -680,7 +610,7 @@
       const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
       pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isFalse(upSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isTrue(upSpy.called);
     });
@@ -689,7 +619,7 @@
       const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
       pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isFalse(downSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isTrue(downSpy.called);
     });
@@ -698,7 +628,7 @@
       const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
       pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isFalse(enterSpy.called);
-      await setupDropdown();
+      await setText(':1');
       pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isTrue(enterSpy.called);
       await element.updateComplete;
@@ -706,12 +636,12 @@
     });
   });
 
-  suite('gr-textarea monospace', () => {
-    let element: GrTextarea;
+  suite('gr-suggestion-textarea monospace', () => {
+    let element: GrSuggestionTextarea;
 
     setup(async () => {
-      element = await fixture<GrTextarea>(
-        html`<gr-textarea monospace></gr-textarea>`
+      element = await fixture<GrSuggestionTextarea>(
+        html`<gr-suggestion-textarea monospace></gr-suggestion-textarea>`
       );
       await element.updateComplete;
     });
@@ -721,12 +651,12 @@
     });
   });
 
-  suite('gr-textarea hideBorder', () => {
-    let element: GrTextarea;
+  suite('gr-suggestion-textarea hideBorder', () => {
+    let element: GrSuggestionTextarea;
 
     setup(async () => {
-      element = await fixture<GrTextarea>(
-        html`<gr-textarea hide-border></gr-textarea>`
+      element = await fixture<GrSuggestionTextarea>(
+        html`<gr-suggestion-textarea hide-border></gr-suggestion-textarea>`
       );
       await element.updateComplete;
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
index a9080e8..661515a 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-tooltip-content';
 import {GrTooltipContent} from './gr-tooltip-content';
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
index be73b87..42a2434 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -7,7 +7,7 @@
 import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
-import {css, html, LitElement, nothing} from 'lit';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, state, query} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {getDocUrl} from '../../../utils/url-util';
@@ -16,8 +16,9 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 import {changeModelToken} from '../../../models/change/change-model';
-import {Comment, isDraft, PatchSetNumber} from '../../../types/common';
+import {Comment, PatchSetNumber} from '../../../types/common';
 import {commentModelToken} from '../gr-comment-model/gr-comment-model';
+import {createUserFixSuggestion} from '../../../utils/comment-util';
 
 declare global {
   interface HTMLElementEventMap {
@@ -44,6 +45,10 @@
 
   @state() comment?: Comment;
 
+  @state() private previewLoaded = false;
+
+  @state() commentedText?: string;
+
   private readonly getConfigModel = resolve(this, configModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
@@ -67,6 +72,11 @@
       () => this.getCommentModel().comment$,
       comment => (this.comment = comment)
     );
+    subscribe(
+      this,
+      () => this.getCommentModel().commentedText$,
+      commentedText => (this.commentedText = commentedText)
+    );
   }
 
   static override get styles() {
@@ -92,8 +102,14 @@
   }
 
   override render() {
-    if (!this.textContent) return nothing;
+    if (!this.textContent || !this.comment || !this.commentedText)
+      return nothing;
     const code = this.textContent;
+    const fixSuggestions = createUserFixSuggestion(
+      this.comment,
+      this.commentedText,
+      code
+    );
     return html`<div class="header">
         <div class="title">
           <span>Suggested edit</span>
@@ -110,6 +126,7 @@
             text=${code}
             multiline
             copyTargetName="Suggested edit"
+            buttonTitle="Copy Suggested edit to clipboard"
           ></gr-copy-clipboard>
         </div>
         <div>
@@ -135,10 +152,21 @@
         </div>
       </div>
       <gr-suggestion-diff-preview
-        .suggestion=${this.textContent}
+        .patchSet=${this.comment?.patch_set}
+        .commentId=${this.comment?.id}
+        .fixSuggestionInfo=${fixSuggestions[0]}
+        .codeText=${code}
+        @preview-loaded=${() => (this.previewLoaded = true)}
       ></gr-suggestion-diff-preview>`;
   }
 
+  override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('commentedText') && this.commentedText) {
+      this.previewLoaded = false;
+    }
+  }
+
   handleShowFix() {
     if (!this.textContent) return;
     fire(this, 'open-user-suggest-preview', {code: this.textContent});
@@ -153,15 +181,13 @@
 
   private isApplyEditDisabled() {
     if (this.comment?.patch_set === undefined) return true;
-    if (isDraft(this.comment)) return true;
-    return this.comment.patch_set !== this.latestPatchNum;
+    return !this.previewLoaded;
   }
 
   private computeApplyEditTooltip() {
     if (this.comment?.patch_set === undefined) return '';
-    return this.comment.patch_set !== this.latestPatchNum
-      ? 'You cannot apply this fix because it is from a previous patchset'
-      : '';
+    if (!this.previewLoaded) return 'Fix is still loading ...';
+    return '';
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
index 52fd687..56d826e 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
@@ -22,6 +22,7 @@
     const commentModel = new CommentModel(getAppContext().restApiService);
     commentModel.updateState({
       comment: createComment(),
+      commentedText: 'Hello World',
     });
     element = (
       await fixture<GrUserSuggestionsFix>(
@@ -52,6 +53,7 @@
           </div>
           <div class="copyButton">
             <gr-copy-clipboard
+              buttontitle="Copy Suggested edit to clipboard"
               hideinput=""
               multiline=""
               text="Hello World"
@@ -75,7 +77,7 @@
               role="button"
               tabindex="-1"
               flatten=""
-              title="You cannot apply this fix because it is from a previous patchset"
+              title="Fix is still loading ..."
               >Apply edit</gr-button
             >
           </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts
index 581b577..62a1461 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {fixture, assert} from '@open-wc/testing';
 import {html} from 'lit';
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index e1fc2ed..1ec5262e 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -271,6 +271,16 @@
         .breadcrumbTooltip {
           white-space: nowrap;
         }
+        .unrelatedChanges {
+          color: var(--primary-button-text-color);
+          background-color: var(--primary-button-background-color);
+
+          &:hover {
+            // TODO(anuragpathak): Update hover colors as per specification.
+            color: var(--primary-button-text-color);
+            background-color: var(--primary-button-background-color);
+          }
+        }
       `,
     ];
   }
@@ -370,8 +380,14 @@
     let classes = 'contextControlButton showContext ';
 
     if (type === ContextButtonType.ALL) {
-      text = `+${pluralize(linesToExpand, 'common line')}`;
-      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      if (this.group.hasNonCommonDeltaGroup()) {
+        text = '+ Unrelated changes';
+        ariaLabel = 'Show unrelated changes';
+        classes += ' unrelatedChanges ';
+      } else {
+        text = `+${pluralize(linesToExpand, 'common line')}`;
+        ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      }
       classes += this.showBoth()
         ? 'centeredButton'
         : this.showAbove()
@@ -483,7 +499,7 @@
    * Creates a container div with partial (+10) expansion buttons (above and/or below).
    */
   private createPartialExpansionButtons() {
-    if (!this.showPartialLinks()) {
+    if (!this.showPartialLinks() || this.group?.hasNonCommonDeltaGroup()) {
       return undefined;
     }
     let aboveButton;
@@ -515,7 +531,8 @@
     if (
       !this.showPartialLinks() ||
       !this.renderPreferences?.use_block_expansion ||
-      this.group?.hasSkipGroup()
+      this.group?.hasSkipGroup() ||
+      this.group?.hasNonCommonDeltaGroup()
     ) {
       return undefined;
     }
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index 20fc9c4..74726bf 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -10,7 +10,10 @@
 import {SyntaxBlock} from '../../../api/diff';
 import {fixture, html, assert} from '@open-wc/testing';
 import {waitEventLoop} from '../../../test/test-utils';
-import {createContextGroup} from '../../../test/test-data-generators';
+import {
+  createContextGroup,
+  createContextGroupWithDelta,
+} from '../../../test/test-data-generators';
 
 suite('gr-context-control tests', () => {
   let element: GrContextControls;
@@ -333,4 +336,16 @@
     assert.equal(tooltipAbove.getAttribute('position'), 'top');
     assert.equal(tooltipBelow.getAttribute('position'), 'bottom');
   });
+
+  test('context control with delta group', async () => {
+    element.group = createContextGroupWithDelta();
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+    assert.equal(buttons.length, 1);
+    assert.equal(buttons[0].textContent!.trim(), '+ Unrelated changes');
+    assert.include([...buttons[0].classList.values()], 'unrelatedChanges');
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
index a8cdff6..088dac6 100644
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2019 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {CoverageType, Side} from '../../../api/diff';
 import {GrCoverageLayer, mergeRanges} from './gr-coverage-layer';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 642610a..1caaebd 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -573,11 +573,11 @@
     // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
     // another rendering cycle will be initiated in `updated()`.
     // prettier-ignore
-    const textElement = line?.text && !this.layersApplied
+    const textElement = !this.layersApplied
       ? html`<gr-diff-text
           ${ref(this.contentRef(side))}
           data-side=${ifDefined(side)}
-          .text=${line?.text}
+          .text=${line?.text ?? ''}
           .tabSize=${this.tabSize}
           .lineLimit=${this.lineLength}
           .isResponsive=${isResponsive(this.responsiveMode)}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
index 1526ce3..346d3fe 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -164,7 +164,9 @@
             >
               <td class="blankLineNum left"></td>
               <td class="blank left no-intraline-info">
-                <div class="contentText" data-side="left"></div>
+                <div class="contentText" data-side="left">
+                  <gr-diff-text data-side="left"></gr-diff-text>
+                </div>
               </td>
               <td class="lineNum right" data-value="1">
                 <button
@@ -226,7 +228,9 @@
               </td>
               <td class="blankLineNum right"></td>
               <td class="blank no-intraline-info right">
-                <div class="contentText" data-side="right"></div>
+                <div class="contentText" data-side="right">
+                  <gr-diff-text data-side="right"></gr-diff-text>
+                </div>
               </td>
             </tr>
             <slot name="post-left-line-1"></slot>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index a9e332a..e1728ff 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -233,6 +233,10 @@
     // it's a shadow dom.
     const {element} = this.findTokenAncestor(e.composedPath()[0]);
     if (element) return;
+    this.removeHighlight();
+  }
+
+  private removeHighlight() {
     this.hoveredElement = undefined;
     this.updateTokenTask?.cancel();
     this.updateTokenHighlight(undefined, 0, undefined);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 8d0050f..8eeaa84 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {
   GrDiffLineType,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
index 70fece3..6f5674c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../gr-diff/gr-diff';
 import './gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
index 378c255..6ee5f18 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {
   TEST_ONLY,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
index 32decb1..e186b72 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-diff-highlight';
 import {getTextOffset} from './gr-range-normalizer';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index 42fa984..8488bd5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -3,13 +3,14 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Observable, combineLatest, from, of} from 'rxjs';
-import {debounceTime, filter, switchMap, withLatestFrom} from 'rxjs/operators';
+import {Observable, combineLatest} from 'rxjs';
+import {debounceTime, filter, map, withLatestFrom} from 'rxjs/operators';
 import {
   CreateCommentEventDetail,
   DiffInfo,
   DiffLayer,
   DiffPreferencesInfo,
+  DiffRangesToFocus,
   DiffResponsiveMode,
   DiffViewMode,
   DisplayLine,
@@ -36,10 +37,6 @@
   GrDiffProcessor,
   ProcessingOptions,
 } from '../gr-diff-processor/gr-diff-processor';
-import {
-  GrDiffProcessorSimplified,
-  ProcessingOptions as ProcessingOptionsSimplified,
-} from '../gr-diff-processor/gr-diff-processor-simplified';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {assert} from '../../../utils/common-util';
 import {countLines, isImageDiff} from '../../../utils/diff-util';
@@ -55,6 +52,7 @@
   renderPrefs: RenderPreferences;
   diffPrefs: DiffPreferencesInfo;
   lineOfInterest?: DisplayLine;
+  diffRangesToFocus?: DiffRangesToFocus;
   comments: GrDiffCommentThread[];
   groups: GrDiffGroup[];
   /** how much context to show for large files */
@@ -215,6 +213,9 @@
       computeKeyLocations(diffState.lineOfInterest, diffState.comments ?? [])
   );
 
+  readonly diffRangesToFocus$: Observable<DiffRangesToFocus | undefined> =
+    select(this.state$, diffState => diffState.diffRangesToFocus);
+
   constructor(
     /**
      * Normally a reference to the <gr-diff> component. Used for firing events
@@ -236,30 +237,31 @@
   }
 
   processDiff() {
-    return combineLatest([this.diff$, this.context$, this.renderPrefs$])
+    return combineLatest([
+      this.diff$,
+      this.context$,
+      this.renderPrefs$,
+      this.diffRangesToFocus$,
+    ])
       .pipe(
         withLatestFrom(this.keyLocations$),
         debounceTime(1),
-        switchMap(([[diff, context, renderPrefs], keyLocations]) => {
-          const options: ProcessingOptions | ProcessingOptionsSimplified = {
-            context,
-            keyLocations,
-            isBinary: !!(isImageDiff(diff) || diff.binary),
-          };
-          if (renderPrefs?.num_lines_rendered_at_once) {
-            options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
-          }
+        map(
+          ([[diff, context, renderPrefs, diffRangesToFocus], keyLocations]) => {
+            const options: ProcessingOptions = {
+              context,
+              keyLocations,
+              isBinary: !!(isImageDiff(diff) || diff.binary),
+              diffRangesToFocus,
+            };
+            if (renderPrefs?.num_lines_rendered_at_once) {
+              options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+            }
 
-          // TODO: When switching to the simplified processor unconditionally,
-          // then we can use map() instead of switchMap().
-          if (renderPrefs?.use_simplified_processor) {
-            const processor = new GrDiffProcessorSimplified(options);
-            return of(processor.process(diff.content));
-          } else {
             const processor = new GrDiffProcessor(options);
-            return from(processor.process(diff.content));
+            return processor.process(diff.content);
           }
-        })
+        )
       )
       .subscribe(groups => {
         this.updateState({groups});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified.ts
deleted file mode 100644
index a306de8..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified.ts
+++ /dev/null
@@ -1,550 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
-import {
-  GrDiffGroup,
-  GrDiffGroupType,
-  hideInContextControl,
-} from '../gr-diff/gr-diff-group';
-import {DiffContent} from '../../../types/diff';
-import {Side} from '../../../constants/constants';
-import {getStringLength} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLineType, LineNumber} from '../../../api/diff';
-import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils';
-
-// visible for testing
-export interface State {
-  lineNums: {
-    left: number;
-    right: number;
-  };
-  chunkIndex: number;
-}
-
-interface ChunkEnd {
-  offset: number;
-  keyLocation: boolean;
-}
-
-/** Interface for listening to the output of the processor. */
-export interface GroupConsumer {
-  addGroup(group: GrDiffGroup): void;
-  clearGroups(): void;
-}
-
-/** Interface for listening to the output of the processor. */
-export interface ProcessingOptions {
-  context: number;
-  keyLocations?: KeyLocations;
-  asyncThreshold?: number;
-  isBinary?: boolean;
-}
-
-/**
- * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
- *
- * Glossary:
- * - "chunk": A single `DiffContent` as returned by the API.
- * - "group": A single `GrDiffGroup` as used for rendering.
- * - "common" chunk/group: A chunk/group that should be considered unchanged
- *   for diffing purposes. This can mean its either actually unchanged, or it
- *   has only whitespace changes.
- * - "key location": A line number and side of the diff that should not be
- *   collapsed e.g. because a comment is attached to it, or because it was
- *   provided in the URL and thus should be visible
- * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
- *   or cannot be collapsed because it contains a key location
- *
- * Here a a number of tasks this processor performs:
- *  - splitting large chunks to allow more granular async rendering
- *  - adding a group for the "File" pseudo line that file-level comments can
- *    be attached to
- *  - replacing common parts of the diff that are outside the user's
- *    context setting and do not have comments with a group representing the
- *    "expand context" widget. This may require splitting a chunk/group so
- *    that the part that is within the context or has comments is shown, while
- *    the rest is not.
- */
-export class GrDiffProcessorSimplified {
-  // visible for testing
-  context: number;
-
-  // visible for testing
-  keyLocations: KeyLocations;
-
-  private isBinary = false;
-
-  private groups: GrDiffGroup[] = [];
-
-  constructor(options: ProcessingOptions) {
-    this.context = options.context;
-    this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
-    this.isBinary = options.isBinary ?? false;
-  }
-
-  /**
-   * Process the diff chunks into GrDiffGroups.
-   *
-   * @return an array of GrDiffGroups
-   */
-  process(chunks: DiffContent[]): GrDiffGroup[] {
-    this.groups = [];
-    this.groups.push(this.makeGroup('LOST'));
-    this.groups.push(this.makeGroup('FILE'));
-
-    this.processChunks(chunks);
-    return this.groups;
-  }
-
-  processChunks(chunks: DiffContent[]) {
-    if (this.isBinary) return;
-
-    const state = {
-      lineNums: {left: 0, right: 0},
-      chunkIndex: 0,
-    };
-    chunks = this.splitCommonChunksWithKeyLocations(chunks);
-
-    while (state.chunkIndex < chunks.length) {
-      const stateUpdate = this.processNext(state, chunks);
-      for (const group of stateUpdate.groups) {
-        this.groups.push(group);
-      }
-      state.lineNums.left += stateUpdate.lineDelta.left;
-      state.lineNums.right += stateUpdate.lineDelta.right;
-      state.chunkIndex = stateUpdate.newChunkIndex;
-    }
-  }
-
-  /**
-   * Process the next uncollapsible chunk, or the next collapsible chunks.
-   */
-  // visible for testing
-  processNext(state: State, chunks: DiffContent[]) {
-    const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
-      chunks,
-      state.chunkIndex
-    );
-    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
-      const chunk = chunks[state.chunkIndex];
-      return {
-        lineDelta: {
-          left: this.linesLeft(chunk).length,
-          right: this.linesRight(chunk).length,
-        },
-        groups: [
-          this.chunkToGroup(
-            chunk,
-            state.lineNums.left + 1,
-            state.lineNums.right + 1
-          ),
-        ],
-        newChunkIndex: state.chunkIndex + 1,
-      };
-    }
-
-    return this.processCollapsibleChunks(
-      state,
-      chunks,
-      firstUncollapsibleChunkIndex
-    );
-  }
-
-  private linesLeft(chunk: DiffContent) {
-    return chunk.ab || chunk.a || [];
-  }
-
-  private linesRight(chunk: DiffContent) {
-    return chunk.ab || chunk.b || [];
-  }
-
-  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
-    let chunkIndex = offset;
-    while (
-      chunkIndex < chunks.length &&
-      this.isCollapsibleChunk(chunks[chunkIndex])
-    ) {
-      chunkIndex++;
-    }
-    return chunkIndex;
-  }
-
-  private isCollapsibleChunk(chunk: DiffContent) {
-    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
-  }
-
-  /**
-   * Process a stretch of collapsible chunks.
-   *
-   * Outputs up to three groups:
-   * 1) Visible context before the hidden common code, unless it's the
-   * very beginning of the file.
-   * 2) Context hidden behind a context bar, unless empty.
-   * 3) Visible context after the hidden common code, unless it's the very
-   * end of the file.
-   */
-  private processCollapsibleChunks(
-    state: State,
-    chunks: DiffContent[],
-    firstUncollapsibleChunkIndex: number
-  ) {
-    const collapsibleChunks = chunks.slice(
-      state.chunkIndex,
-      firstUncollapsibleChunkIndex
-    );
-    const lineCount = collapsibleChunks.reduce(
-      (sum, chunk) => sum + this.commonChunkLength(chunk),
-      0
-    );
-
-    let groups = this.chunksToGroups(
-      collapsibleChunks,
-      state.lineNums.left + 1,
-      state.lineNums.right + 1
-    );
-
-    const hasSkippedGroup = !!groups.find(g => g.skip);
-    if (this.context !== FULL_CONTEXT || hasSkippedGroup) {
-      const contextNumLines = this.context > 0 ? this.context : 0;
-      const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
-      const hiddenEnd =
-        lineCount -
-        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
-      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
-    }
-
-    return {
-      lineDelta: {
-        left: lineCount,
-        right: lineCount,
-      },
-      groups,
-      newChunkIndex: firstUncollapsibleChunkIndex,
-    };
-  }
-
-  private commonChunkLength(chunk: DiffContent) {
-    if (chunk.skip) {
-      return chunk.skip;
-    }
-    console.assert(!!chunk.ab || !!chunk.common);
-
-    console.assert(
-      !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
-      'common chunk needs same number of a and b lines: ',
-      chunk
-    );
-    return this.linesLeft(chunk).length;
-  }
-
-  private chunksToGroups(
-    chunks: DiffContent[],
-    offsetLeft: number,
-    offsetRight: number
-  ): GrDiffGroup[] {
-    return chunks.map(chunk => {
-      const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
-      const chunkLength = this.commonChunkLength(chunk);
-      offsetLeft += chunkLength;
-      offsetRight += chunkLength;
-      return group;
-    });
-  }
-
-  private chunkToGroup(
-    chunk: DiffContent,
-    offsetLeft: number,
-    offsetRight: number
-  ): GrDiffGroup {
-    const type =
-      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
-    const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
-    const options = {
-      moveDetails: chunk.move_details,
-      dueToRebase: !!chunk.due_to_rebase,
-      ignoredWhitespaceOnly: !!chunk.common,
-      keyLocation: !!chunk.keyLocation,
-    };
-    if (chunk.skip !== undefined) {
-      return new GrDiffGroup({
-        type,
-        skip: chunk.skip,
-        offsetLeft,
-        offsetRight,
-        ...options,
-      });
-    } else {
-      return new GrDiffGroup({
-        type,
-        lines,
-        ...options,
-      });
-    }
-  }
-
-  private linesFromChunk(
-    chunk: DiffContent,
-    offsetLeft: number,
-    offsetRight: number
-  ) {
-    if (chunk.ab) {
-      return chunk.ab.map((row, i) =>
-        this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
-      );
-    }
-    let lines: GrDiffLine[] = [];
-    if (chunk.a) {
-      // Avoiding a.push(...b) because that causes callstack overflows for
-      // large b, which can occur when large files are added removed.
-      lines = lines.concat(
-        this.linesFromRows(
-          GrDiffLineType.REMOVE,
-          chunk.a,
-          offsetLeft,
-          chunk.edit_a
-        )
-      );
-    }
-    if (chunk.b) {
-      // Avoiding a.push(...b) because that causes callstack overflows for
-      // large b, which can occur when large files are added removed.
-      lines = lines.concat(
-        this.linesFromRows(
-          GrDiffLineType.ADD,
-          chunk.b,
-          offsetRight,
-          chunk.edit_b
-        )
-      );
-    }
-    return lines;
-  }
-
-  // visible for testing
-  linesFromRows(
-    lineType: GrDiffLineType,
-    rows: string[],
-    offset: number,
-    intralineInfos?: number[][]
-  ): GrDiffLine[] {
-    const grDiffHighlights = intralineInfos
-      ? this.convertIntralineInfos(rows, intralineInfos)
-      : undefined;
-    return rows.map((row, i) =>
-      this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
-    );
-  }
-
-  private lineFromRow(
-    type: GrDiffLineType,
-    offsetLeft: number,
-    offsetRight: number,
-    row: string,
-    i: number,
-    highlights?: Highlights[]
-  ): GrDiffLine {
-    const line = new GrDiffLine(type);
-    line.text = row;
-    if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
-    if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
-    if (highlights) {
-      line.hasIntralineInfo = true;
-      line.highlights = highlights.filter(hl => hl.contentIndex === i);
-    } else {
-      line.hasIntralineInfo = false;
-    }
-    return line;
-  }
-
-  private makeGroup(number: LineNumber) {
-    const line = new GrDiffLine(GrDiffLineType.BOTH);
-    line.beforeNumber = number;
-    line.afterNumber = number;
-    return new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [line]});
-  }
-
-  /**
-   * In order to show key locations, such as comments, out of the bounds of
-   * the selected context, treat them as separate chunks within the model so
-   * that the content (and context surrounding it) renders correctly.
-   *
-   * @param chunks DiffContents as returned from server.
-   * @return Finer grained DiffContents.
-   */
-  // visible for testing
-  splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
-    const result = [];
-    let leftLineNum = 1;
-    let rightLineNum = 1;
-
-    for (const chunk of chunks) {
-      // If it isn't a common chunk, append it as-is and update line numbers.
-      if (!chunk.ab && !chunk.skip && !chunk.common) {
-        if (chunk.a) {
-          leftLineNum += chunk.a.length;
-        }
-        if (chunk.b) {
-          rightLineNum += chunk.b.length;
-        }
-        result.push(chunk);
-        continue;
-      }
-
-      if (chunk.common && chunk.a!.length !== chunk.b!.length) {
-        throw new Error(
-          'DiffContent with common=true must always have equal length'
-        );
-      }
-      const numLines = this.commonChunkLength(chunk);
-      const chunkEnds = this.findChunkEndsAtKeyLocations(
-        numLines,
-        leftLineNum,
-        rightLineNum
-      );
-      leftLineNum += numLines;
-      rightLineNum += numLines;
-
-      if (chunk.skip) {
-        result.push({
-          ...chunk,
-          skip: chunk.skip,
-          keyLocation: false,
-        });
-      } else if (chunk.ab) {
-        result.push(
-          ...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
-            ({lines, keyLocation}) => {
-              return {
-                ...chunk,
-                ab: lines,
-                keyLocation,
-              };
-            }
-          )
-        );
-      } else if (chunk.common) {
-        const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
-        const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
-        result.push(
-          ...aChunks.map(({lines, keyLocation}, i) => {
-            return {
-              ...chunk,
-              a: lines,
-              b: bChunks[i].lines,
-              keyLocation,
-            };
-          })
-        );
-      }
-    }
-
-    return result;
-  }
-
-  /**
-   * @return Offsets of the new chunk ends, including whether it's a key
-   * location.
-   */
-  private findChunkEndsAtKeyLocations(
-    numLines: number,
-    leftOffset: number,
-    rightOffset: number
-  ): ChunkEnd[] {
-    const result = [];
-    let lastChunkEnd = 0;
-    for (let i = 0; i < numLines; i++) {
-      // If this line should not be collapsed.
-      if (
-        this.keyLocations[Side.LEFT][leftOffset + i] ||
-        this.keyLocations[Side.RIGHT][rightOffset + i]
-      ) {
-        // If any lines have been accumulated into the chunk leading up to
-        // this non-collapse line, then add them as a chunk and start a new
-        // one.
-        if (i > lastChunkEnd) {
-          result.push({offset: i, keyLocation: false});
-          lastChunkEnd = i;
-        }
-
-        // Add the non-collapse line as its own chunk.
-        result.push({offset: i + 1, keyLocation: true});
-      }
-    }
-
-    if (numLines > lastChunkEnd) {
-      result.push({offset: numLines, keyLocation: false});
-    }
-
-    return result;
-  }
-
-  private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
-    const result = [];
-    let lastChunkEndOffset = 0;
-    for (const {offset, keyLocation} of chunkEnds) {
-      if (lastChunkEndOffset === offset) continue;
-      result.push({
-        lines: lines.slice(lastChunkEndOffset, offset),
-        keyLocation,
-      });
-      lastChunkEndOffset = offset;
-    }
-    return result;
-  }
-
-  /**
-   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
-   * for rendering.
-   */
-  // visible for testing
-  convertIntralineInfos(
-    rows: string[],
-    intralineInfos: number[][]
-  ): Highlights[] {
-    // +1 to account for the \n that is not part of the rows passed here
-    const lineLengths = rows.map(r => getStringLength(r) + 1);
-
-    let rowIndex = 0;
-    let idx = 0;
-    const normalized = [];
-    for (const [skipLength, markLength] of intralineInfos) {
-      let lineLength = lineLengths[rowIndex];
-      let j = 0;
-      while (j < skipLength) {
-        if (idx === lineLength) {
-          idx = 0;
-          lineLength = lineLengths[++rowIndex];
-          continue;
-        }
-        idx++;
-        j++;
-      }
-      let lineHighlight: Highlights = {
-        contentIndex: rowIndex,
-        startIndex: idx,
-      };
-
-      j = 0;
-      while (lineLength && j < markLength) {
-        if (idx === lineLength) {
-          idx = 0;
-          lineLength = lineLengths[++rowIndex];
-          normalized.push(lineHighlight);
-          lineHighlight = {
-            contentIndex: rowIndex,
-            startIndex: idx,
-          };
-          continue;
-        }
-        idx++;
-        j++;
-      }
-      lineHighlight.endIndex = idx;
-      normalized.push(lineHighlight);
-    }
-    return normalized;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified_test.ts
deleted file mode 100644
index abd224f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor-simplified_test.ts
+++ /dev/null
@@ -1,987 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-diff-processor';
-import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {
-  GrDiffProcessorSimplified,
-  ProcessingOptions,
-  State,
-} from './gr-diff-processor-simplified';
-import {DiffContent} from '../../../types/diff';
-import {assert} from '@open-wc/testing';
-import {FILE, GrDiffLineType} from '../../../api/diff';
-import {FULL_CONTEXT} from '../gr-diff/gr-diff-utils';
-
-suite('gr-diff-processor tests', () => {
-  const loremIpsum =
-    'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
-    'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
-    'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
-    'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
-    'fugit assum per.';
-
-  let processor: GrDiffProcessorSimplified;
-  let options: ProcessingOptions = {
-    context: 4,
-  };
-
-  setup(() => {});
-
-  suite('not logged in', () => {
-    setup(() => {
-      options = {context: 4};
-      processor = new GrDiffProcessorSimplified(options);
-    });
-
-    test('process loaded content', async () => {
-      const content: DiffContent[] = [
-        {
-          ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
-        },
-        {
-          a: ['  Welcome ', '  to the wooorld of tomorrow!'],
-          b: ['  Hello, world!'],
-        },
-        {
-          ab: [
-            'Leela: This is the only place the ship can’t hear us, so ',
-            'everyone pretend to shower.',
-            'Fry: Same as every day. Got it.',
-          ],
-        },
-      ];
-
-      const groups = await processor.process(content);
-      groups.shift(); // remove portedThreadsWithoutRangeGroup
-      assert.equal(groups.length, 4);
-
-      let group = groups[0];
-      assert.equal(group.type, GrDiffGroupType.BOTH);
-      assert.equal(group.lines.length, 1);
-      assert.equal(group.lines[0].text, '');
-      assert.equal(group.lines[0].beforeNumber, FILE);
-      assert.equal(group.lines[0].afterNumber, FILE);
-
-      group = groups[1];
-      assert.equal(group.type, GrDiffGroupType.BOTH);
-      assert.equal(group.lines.length, 2);
-
-      function beforeNumberFn(l: GrDiffLine) {
-        return l.beforeNumber;
-      }
-      function afterNumberFn(l: GrDiffLine) {
-        return l.afterNumber;
-      }
-      function textFn(l: GrDiffLine) {
-        return l.text;
-      }
-
-      assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
-      assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
-      assert.deepEqual(group.lines.map(textFn), [
-        '<!DOCTYPE html>',
-        '<meta charset="utf-8">',
-      ]);
-
-      group = groups[2];
-      assert.equal(group.type, GrDiffGroupType.DELTA);
-      assert.equal(group.lines.length, 3);
-      assert.equal(group.adds.length, 1);
-      assert.equal(group.removes.length, 2);
-      assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
-      assert.deepEqual(group.adds.map(afterNumberFn), [3]);
-      assert.deepEqual(group.removes.map(textFn), [
-        '  Welcome ',
-        '  to the wooorld of tomorrow!',
-      ]);
-      assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
-
-      group = groups[3];
-      assert.equal(group.type, GrDiffGroupType.BOTH);
-      assert.equal(group.lines.length, 3);
-      assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
-      assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
-      assert.deepEqual(group.lines.map(textFn), [
-        'Leela: This is the only place the ship can’t hear us, so ',
-        'everyone pretend to shower.',
-        'Fry: Same as every day. Got it.',
-      ]);
-    });
-
-    test('first group is for file', async () => {
-      const content = [{b: ['foo']}];
-
-      const groups = await processor.process(content);
-      groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-      assert.equal(groups[0].type, GrDiffGroupType.BOTH);
-      assert.equal(groups[0].lines.length, 1);
-      assert.equal(groups[0].lines[0].text, '');
-      assert.equal(groups[0].lines[0].beforeNumber, FILE);
-      assert.equal(groups[0].lines[0].afterNumber, FILE);
-    });
-
-    suite('context groups', async () => {
-      test('at the beginning, larger than context', async () => {
-        options.context = 10;
-        processor = new GrDiffProcessorSimplified(options);
-        const content = [
-          {
-            ab: Array.from<string>({length: 100}).fill(
-              'all work and no play make jack a dull boy'
-            ),
-          },
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        const groups = await processor.process(content);
-        // group[0] is the LOST group
-        // group[1] is the FILE group
-
-        assert.equal(groups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
-        assert.instanceOf(groups[2].contextGroups[0], GrDiffGroup);
-        assert.equal(groups[2].contextGroups[0].lines.length, 90);
-        for (const l of groups[2].contextGroups[0].lines) {
-          assert.equal(l.text, 'all work and no play make jack a dull boy');
-        }
-
-        assert.equal(groups[3].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[3].lines.length, 10);
-        for (const l of groups[3].lines) {
-          assert.equal(l.text, 'all work and no play make jack a dull boy');
-        }
-      });
-
-      test('at the beginning with skip chunks', async () => {
-        options.context = 10;
-        processor = new GrDiffProcessorSimplified(options);
-        const content = [
-          {
-            ab: Array.from<string>({length: 20}).fill(
-              'all work and no play make jack a dull boy'
-            ),
-          },
-          {skip: 43900},
-          {ab: Array.from<string>({length: 30}).fill('some other content')},
-          {a: ['some other content']},
-        ];
-
-        const groups = await processor.process(content);
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-
-        const commonGroup = groups[1];
-
-        // Hidden context before
-        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
-        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
-        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
-        for (const l of commonGroup.contextGroups[0].lines) {
-          assert.equal(l.text, 'all work and no play make jack a dull boy');
-        }
-
-        // Skipped group
-        const skipGroup = commonGroup.contextGroups[1];
-        assert.equal(skipGroup.skip, 43900);
-        const expectedRange = {
-          left: {start_line: 21, end_line: 43920},
-          right: {start_line: 21, end_line: 43920},
-        };
-        assert.deepEqual(skipGroup.lineRange, expectedRange);
-
-        // Hidden context after
-        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
-        for (const l of commonGroup.contextGroups[2].lines) {
-          assert.equal(l.text, 'some other content');
-        }
-
-        // Displayed lines
-        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[2].lines.length, 10);
-        for (const l of groups[2].lines) {
-          assert.equal(l.text, 'some other content');
-        }
-      });
-
-      test('at the beginning, smaller than context', async () => {
-        options.context = 10;
-        processor = new GrDiffProcessorSimplified(options);
-        const content = [
-          {
-            ab: Array.from<string>({length: 5}).fill(
-              'all work and no play make jack a dull boy'
-            ),
-          },
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        const groups = await processor.process(content);
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-
-        assert.equal(groups[1].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[1].lines.length, 5);
-        for (const l of groups[1].lines) {
-          assert.equal(l.text, 'all work and no play make jack a dull boy');
-        }
-      });
-
-      test('at the end, larger than context', async () => {
-        options.context = 10;
-        processor = new GrDiffProcessorSimplified(options);
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {
-            ab: Array.from<string>({length: 100}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-          },
-        ];
-
-        const groups = await processor.process(content);
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-        // group[1] is the "a" group
-
-        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[2].lines.length, 10);
-        for (const l of groups[2].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-
-        assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
-        assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
-        assert.equal(groups[3].contextGroups[0].lines.length, 90);
-        for (const l of groups[3].contextGroups[0].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-      });
-
-      test('at the end, smaller than context', async () => {
-        options.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {
-            ab: Array.from<string>({length: 5}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-          },
-        ];
-
-        const groups = await processor.process(content);
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-        // group[1] is the "a" group
-
-        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[2].lines.length, 5);
-        for (const l of groups[2].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-      });
-
-      test('for interleaved ab and common: true chunks', async () => {
-        options.context = 10;
-        processor = new GrDiffProcessorSimplified(options);
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {
-            ab: Array.from<string>({length: 3}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-          },
-          {
-            a: Array.from<string>({length: 3}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-            b: Array.from<string>({length: 3}).fill(
-              '  all work and no play make jill a dull girl'
-            ),
-            common: true,
-          },
-          {
-            ab: Array.from<string>({length: 3}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-          },
-          {
-            a: Array.from<string>({length: 3}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-            b: Array.from<string>({length: 3}).fill(
-              '  all work and no play make jill a dull girl'
-            ),
-            common: true,
-          },
-          {
-            ab: Array.from<string>({length: 3}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-          },
-        ];
-
-        const groups = await processor.process(content);
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-        // group[1] is the "a" group
-
-        // The first three interleaved chunks are completely shown because
-        // they are part of the context (3 * 3 <= 10)
-
-        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[2].lines.length, 3);
-        for (const l of groups[2].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-
-        assert.equal(groups[3].type, GrDiffGroupType.DELTA);
-        assert.equal(groups[3].lines.length, 6);
-        assert.equal(groups[3].adds.length, 3);
-        assert.equal(groups[3].removes.length, 3);
-        for (const l of groups[3].removes) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-        for (const l of groups[3].adds) {
-          assert.equal(l.text, '  all work and no play make jill a dull girl');
-        }
-
-        assert.equal(groups[4].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[4].lines.length, 3);
-        for (const l of groups[4].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-
-        // The next chunk is partially shown, so it results in two groups
-
-        assert.equal(groups[5].type, GrDiffGroupType.DELTA);
-        assert.equal(groups[5].lines.length, 2);
-        assert.equal(groups[5].adds.length, 1);
-        assert.equal(groups[5].removes.length, 1);
-        for (const l of groups[5].removes) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-        for (const l of groups[5].adds) {
-          assert.equal(l.text, '  all work and no play make jill a dull girl');
-        }
-
-        assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
-        assert.equal(groups[6].contextGroups.length, 2);
-
-        assert.equal(groups[6].contextGroups[0].lines.length, 4);
-        assert.equal(groups[6].contextGroups[0].removes.length, 2);
-        assert.equal(groups[6].contextGroups[0].adds.length, 2);
-        for (const l of groups[6].contextGroups[0].removes) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-        for (const l of groups[6].contextGroups[0].adds) {
-          assert.equal(l.text, '  all work and no play make jill a dull girl');
-        }
-
-        // The final chunk is completely hidden
-        assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[6].contextGroups[1].lines.length, 3);
-        for (const l of groups[6].contextGroups[1].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-      });
-
-      test('in the middle, larger than context', async () => {
-        options.context = 10;
-        processor = new GrDiffProcessorSimplified(options);
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {
-            ab: Array.from<string>({length: 100}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-          },
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        const groups = await processor.process(content);
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-        // group[1] is the "a" group
-
-        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[2].lines.length, 10);
-        for (const l of groups[2].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-
-        assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
-        assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
-        assert.equal(groups[3].contextGroups[0].lines.length, 80);
-        for (const l of groups[3].contextGroups[0].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-
-        assert.equal(groups[4].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[4].lines.length, 10);
-        for (const l of groups[4].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-      });
-
-      test('in the middle, smaller than context', async () => {
-        options.context = 10;
-        processor = new GrDiffProcessorSimplified(options);
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {
-            ab: Array.from<string>({length: 5}).fill(
-              'all work and no play make jill a dull girl'
-            ),
-          },
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        const groups = await processor.process(content);
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-        // group[1] is the "a" group
-
-        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[2].lines.length, 5);
-        for (const l of groups[2].lines) {
-          assert.equal(l.text, 'all work and no play make jill a dull girl');
-        }
-      });
-    });
-
-    test('in the middle with skip chunks', async () => {
-      options.context = 10;
-      processor = new GrDiffProcessorSimplified(options);
-      const content = [
-        {a: ['all work and no play make andybons a dull boy']},
-        {
-          ab: Array.from<string>({length: 20}).fill(
-            'all work and no play make jill a dull girl'
-          ),
-        },
-        {skip: 60},
-        {
-          ab: Array.from<string>({length: 20}).fill(
-            'all work and no play make jill a dull girl'
-          ),
-        },
-        {a: ['all work and no play make andybons a dull boy']},
-      ];
-
-      const groups = await processor.process(content);
-      groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-      // group[0] is the file group
-      // group[1] is the chunk with a
-      // group[2] is the displayed part of ab before
-
-      const commonGroup = groups[3];
-
-      // Hidden context before
-      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
-      assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
-      assert.equal(commonGroup.contextGroups[0].lines.length, 10);
-      for (const l of commonGroup.contextGroups[0].lines) {
-        assert.equal(l.text, 'all work and no play make jill a dull girl');
-      }
-
-      // Skipped group
-      const skipGroup = commonGroup.contextGroups[1];
-      assert.equal(skipGroup.skip, 60);
-      const expectedRange = {
-        left: {start_line: 22, end_line: 81},
-        right: {start_line: 21, end_line: 80},
-      };
-      assert.deepEqual(skipGroup.lineRange, expectedRange);
-
-      // Hidden context after
-      assert.equal(commonGroup.contextGroups[2].lines.length, 10);
-      for (const l of commonGroup.contextGroups[2].lines) {
-        assert.equal(l.text, 'all work and no play make jill a dull girl');
-      }
-      // group[4] is the displayed part of the second ab
-    });
-
-    test('works with skip === 0', async () => {
-      options.context = 3;
-      processor = new GrDiffProcessorSimplified(options);
-      const content = [
-        {
-          skip: 0,
-        },
-        {
-          b: [
-            '/**',
-            ' * @license',
-            ' * Copyright 2015 Google LLC',
-            ' * SPDX-License-Identifier: Apache-2.0',
-            ' */',
-            "import '../../../test/common-test-setup';",
-          ],
-        },
-      ];
-      await processor.process(content);
-    });
-
-    test('break up common diff chunks', () => {
-      options.keyLocations = {
-        left: {1: true},
-        right: {10: true},
-      };
-      processor = new GrDiffProcessorSimplified(options);
-
-      const content = [
-        {
-          ab: [
-            'copy',
-            '',
-            'asdf',
-            'qwer',
-            'zxcv',
-            '',
-            'http',
-            '',
-            'vbnm',
-            'dfgh',
-            'yuio',
-            'sdfg',
-            '1234',
-          ],
-        },
-      ];
-      const result = processor.splitCommonChunksWithKeyLocations(content);
-      assert.deepEqual(result, [
-        {
-          ab: ['copy'],
-          keyLocation: true,
-        },
-        {
-          ab: ['', 'asdf', 'qwer', 'zxcv', '', 'http', '', 'vbnm'],
-          keyLocation: false,
-        },
-        {
-          ab: ['dfgh'],
-          keyLocation: true,
-        },
-        {
-          ab: ['yuio', 'sdfg', '1234'],
-          keyLocation: false,
-        },
-      ]);
-    });
-
-    test('does not break-down common chunks w/ context', () => {
-      const ab = Array(75)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      const content = [{ab}];
-      processor.context = 4;
-      const result = processor.splitCommonChunksWithKeyLocations(content);
-      assert.equal(result.length, 1);
-      assert.deepEqual(result[0].ab, content[0].ab);
-      assert.isFalse(result[0].keyLocation);
-    });
-
-    test('intraline normalization', () => {
-      // The content and highlights are in the format returned by the Gerrit
-      // REST API.
-      let content = [
-        '      <section class="summary">',
-        '        <gr-formatted-text content="' +
-          '[[_computeCurrentRevisionMessage(change)]]"></gr-formatted-text>',
-        '      </section>',
-      ];
-      let highlights = [
-        [31, 34],
-        [42, 26],
-      ];
-
-      let results = processor.convertIntralineInfos(content, highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 31,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 0,
-          endIndex: 33,
-        },
-        {
-          contentIndex: 1,
-          endIndex: 101,
-          startIndex: 75,
-        },
-      ]);
-      const lines = processor.linesFromRows(
-        GrDiffLineType.BOTH,
-        content,
-        0,
-        highlights
-      );
-      assert.equal(lines.length, 3);
-      assert.isTrue(lines[0].hasIntralineInfo);
-      assert.equal(lines[0].highlights.length, 1);
-      assert.isTrue(lines[1].hasIntralineInfo);
-      assert.equal(lines[1].highlights.length, 2);
-      assert.isTrue(lines[2].hasIntralineInfo);
-      assert.equal(lines[2].highlights.length, 0);
-
-      content = [
-        '        this._path = value.path;',
-        '',
-        '        // When navigating away from the page, there is a ' +
-          'possibility that the',
-        '        // patch number is no longer a part of the URL ' +
-          '(say when navigating to',
-        '        // the top-level change info view) and therefore ' +
-          'undefined in `params`.',
-        '        if (!this._patchRange.patchNum) {',
-      ];
-      highlights = [
-        [14, 17],
-        [11, 70],
-        [12, 67],
-        [12, 67],
-        [14, 29],
-      ];
-      results = processor.convertIntralineInfos(content, highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 14,
-          endIndex: 31,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 8,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 3,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 4,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 5,
-          startIndex: 12,
-          endIndex: 41,
-        },
-      ]);
-
-      content = ['🙈 a', '🙉 b', '🙊 c'];
-      highlights = [[2, 7]];
-      results = processor.convertIntralineInfos(content, highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 2,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 0,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 0,
-          endIndex: 1,
-        },
-      ]);
-    });
-
-    test('image diffs', async () => {
-      const content = Array(200).fill({ab: ['', '']});
-      options.isBinary = true;
-      processor = new GrDiffProcessorSimplified(options);
-      const groups = await processor.process(content);
-      assert.equal(groups.length, 2);
-
-      // Image diffs don't process content, just the 'FILE' line.
-      assert.equal(groups[0].lines.length, 1);
-    });
-
-    suite('processNext', () => {
-      let rows: string[];
-
-      setup(() => {
-        rows = loremIpsum.split(' ');
-      });
-
-      test('FULL_CONTEXT', () => {
-        processor.context = FULL_CONTEXT;
-        const state: State = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
-        const result = processor.processNext(state, chunks);
-
-        // Results in one, uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1);
-        assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
-        assert.equal(result.groups[0].lines.length, rows.length);
-
-        // Line numbers are set correctly.
-        assert.equal(
-          result.groups[0].lines[0].beforeNumber,
-          state.lineNums.left + 1
-        );
-        assert.equal(
-          result.groups[0].lines[0].afterNumber,
-          state.lineNums.right + 1
-        );
-
-        assert.equal(
-          result.groups[0].lines[rows.length - 1].beforeNumber,
-          state.lineNums.left + rows.length
-        );
-        assert.equal(
-          result.groups[0].lines[rows.length - 1].afterNumber,
-          state.lineNums.right + rows.length
-        );
-      });
-
-      test('FULL_CONTEXT with skip chunks still get collapsed', () => {
-        processor.context = FULL_CONTEXT;
-        const lineNums = {left: 10, right: 100};
-        const state = {
-          lineNums,
-          chunkIndex: 1,
-        };
-        const skip = 10000;
-        const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
-        const result = processor.processNext(state, chunks);
-        // Results in one, uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1);
-        assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
-
-        // Skip and ab group are hidden in the same context control
-        assert.equal(result.groups[0].contextGroups.length, 2);
-        const [skippedGroup, abGroup] = result.groups[0].contextGroups;
-
-        // Line numbers are set correctly.
-        assert.deepEqual(skippedGroup.lineRange, {
-          left: {
-            start_line: lineNums.left + 1,
-            end_line: lineNums.left + skip,
-          },
-          right: {
-            start_line: lineNums.right + 1,
-            end_line: lineNums.right + skip,
-          },
-        });
-
-        assert.deepEqual(abGroup.lineRange, {
-          left: {
-            start_line: lineNums.left + skip + 1,
-            end_line: lineNums.left + skip + rows.length,
-          },
-          right: {
-            start_line: lineNums.right + skip + 1,
-            end_line: lineNums.right + skip + rows.length,
-          },
-        });
-      });
-
-      test('with context', () => {
-        processor.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
-        const result = processor.processNext(state, chunks);
-        const expectedCollapseSize = rows.length - 2 * processor.context;
-
-        assert.equal(result.groups.length, 3, 'Results in three groups');
-
-        // The first and last are uncollapsed context, whereas the middle has
-        // a single context-control line.
-        assert.equal(result.groups[0].lines.length, processor.context);
-        assert.equal(result.groups[2].lines.length, processor.context);
-
-        // The collapsed group has the hidden lines as its context group.
-        assert.equal(
-          result.groups[1].contextGroups[0].lines.length,
-          expectedCollapseSize
-        );
-      });
-
-      test('first', () => {
-        processor.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 0,
-        };
-        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
-        const result = processor.processNext(state, chunks);
-        const expectedCollapseSize = rows.length - processor.context;
-
-        assert.equal(result.groups.length, 2, 'Results in two groups');
-
-        // Only the first group is collapsed.
-        assert.equal(result.groups[1].lines.length, processor.context);
-
-        // The collapsed group has the hidden lines as its context group.
-        assert.equal(
-          result.groups[0].contextGroups[0].lines.length,
-          expectedCollapseSize
-        );
-      });
-
-      test('few-rows', () => {
-        // Only ten rows.
-        rows = rows.slice(0, 10);
-        processor.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 0,
-        };
-        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
-        const result = processor.processNext(state, chunks);
-
-        // Results in one uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1, 'Results in one group');
-        assert.equal(result.groups[0].lines.length, rows.length);
-      });
-
-      test('no single line collapse', () => {
-        rows = rows.slice(0, 7);
-        processor.context = 3;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
-        const result = processor.processNext(state, chunks);
-
-        // Results in one uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1, 'Results in one group');
-        assert.equal(result.groups[0].lines.length, rows.length);
-      });
-
-      suite('with key location', () => {
-        let state: State;
-        let chunks: DiffContent[];
-
-        setup(() => {
-          state = {
-            lineNums: {left: 10, right: 100},
-            chunkIndex: 0,
-          };
-          processor.context = 10;
-          chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
-        });
-
-        test('context before', () => {
-          state.chunkIndex = 0;
-          const result = processor.processNext(state, chunks);
-
-          // The first chunk is split into two groups:
-          // 1) A context-control, hiding everything but the context before
-          //    the key location.
-          // 2) The context before the key location.
-          // The key location is not processed in this call to processNext
-          assert.equal(result.groups.length, 2);
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(
-            result.groups[0].contextGroups[0].lines.length,
-            rows.length - processor.context
-          );
-          assert.equal(result.groups[1].lines.length, processor.context);
-        });
-
-        test('key location itself', () => {
-          state.chunkIndex = 1;
-          const result = processor.processNext(state, chunks);
-
-          // The second chunk results in a single group, that is just the
-          // line with the key location
-          assert.equal(result.groups.length, 1);
-          assert.equal(result.groups[0].lines.length, 1);
-          assert.equal(result.lineDelta.left, 1);
-          assert.equal(result.lineDelta.right, 1);
-        });
-
-        test('context after', () => {
-          state.chunkIndex = 2;
-          const result = processor.processNext(state, chunks);
-
-          // The last chunk is split into two groups:
-          // 1) The context after the key location.
-          // 1) A context-control, hiding everything but the context after the
-          //    key location.
-          assert.equal(result.groups.length, 2);
-          assert.equal(result.groups[0].lines.length, processor.context);
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(
-            result.groups[1].contextGroups[0].lines.length,
-            rows.length - processor.context
-          );
-        });
-      });
-    });
-
-    suite('gr-diff-processor helpers', () => {
-      let rows: string[];
-
-      setup(() => {
-        rows = loremIpsum.split(' ');
-      });
-
-      test('linesFromRows', () => {
-        const startLineNum = 10;
-        let result = processor.linesFromRows(
-          GrDiffLineType.ADD,
-          rows,
-          startLineNum + 1
-        );
-
-        assert.equal(result.length, rows.length);
-        assert.equal(result[0].type, GrDiffLineType.ADD);
-        assert.notOk(result[0].hasIntralineInfo);
-        assert.equal(result[0].afterNumber, startLineNum + 1);
-        assert.notOk(result[0].beforeNumber);
-        assert.equal(
-          result[result.length - 1].afterNumber,
-          startLineNum + rows.length
-        );
-        assert.notOk(result[result.length - 1].beforeNumber);
-
-        result = processor.linesFromRows(
-          GrDiffLineType.REMOVE,
-          rows,
-          startLineNum + 1
-        );
-
-        assert.equal(result.length, rows.length);
-        assert.equal(result[0].type, GrDiffLineType.REMOVE);
-        assert.notOk(result[0].hasIntralineInfo);
-        assert.equal(result[0].beforeNumber, startLineNum + 1);
-        assert.notOk(result[0].afterNumber);
-        assert.equal(
-          result[result.length - 1].beforeNumber,
-          startLineNum + rows.length
-        );
-        assert.notOk(result[result.length - 1].afterNumber);
-      });
-    });
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 5db6db9..89bb49e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -9,10 +9,8 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {DiffContent} from '../../../types/diff';
+import {DiffContent, DiffRangesToFocus} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {assert} from '../../../utils/common-util';
 import {getStringLength} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLineType, LineNumber} from '../../../api/diff';
 import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils';
@@ -31,19 +29,6 @@
   keyLocation: boolean;
 }
 
-/**
- * The maximum size for an addition or removal chunk before it is broken down
- * into a series of chunks that are this size at most.
- *
- * Note: The value of 120 is chosen so that it is larger than the default
- * asyncThreshold of 64, but feel free to tune this constant to your
- * performance needs.
- */
-function calcMaxGroupSize(asyncThreshold?: number): number {
-  if (!asyncThreshold) return 120;
-  return asyncThreshold * 2;
-}
-
 /** Interface for listening to the output of the processor. */
 export interface GroupConsumer {
   addGroup(group: GrDiffGroup): void;
@@ -56,6 +41,7 @@
   keyLocations?: KeyLocations;
   asyncThreshold?: number;
   isBinary?: boolean;
+  diffRangesToFocus?: DiffRangesToFocus;
 }
 
 /**
@@ -90,114 +76,52 @@
   // visible for testing
   keyLocations: KeyLocations;
 
-  private asyncThreshold: number;
+  private isBinary = false;
 
-  private isBinary: boolean;
+  private groups: GrDiffGroup[] = [];
 
   // visible for testing
-  isScrolling?: boolean;
-
-  /** Just for making sure that process() is only called once. */
-  private isStarted = false;
-
-  /** Indicates that processing should be stopped. */
-  private isCancelled = false;
-
-  private resetIsScrollingTask?: DelayedTask;
-
-  private readonly groups: GrDiffGroup[] = [];
+  diffRangesToFocus?: DiffRangesToFocus;
 
   constructor(options: ProcessingOptions) {
     this.context = options.context;
-    this.asyncThreshold = options.asyncThreshold ?? 64;
     this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
     this.isBinary = options.isBinary ?? false;
+    this.diffRangesToFocus = options.diffRangesToFocus;
   }
 
-  private readonly handleWindowScroll = () => {
-    this.isScrolling = true;
-    this.resetIsScrollingTask = debounce(
-      this.resetIsScrollingTask,
-      () => (this.isScrolling = false),
-      50
-    );
-  };
-
   /**
-   * Asynchronously process the diff chunks into groups. As it processes, it
-   * will splice groups into the `groups` property of the component.
+   * Process the diff chunks into GrDiffGroups.
    *
-   * @return A promise that resolves with an
-   * array of GrDiffGroups when the diff is completely processed.
+   * @return an array of GrDiffGroups
    */
-  async process(chunks: DiffContent[]): Promise<GrDiffGroup[]> {
-    assert(this.isStarted === false, 'diff processor cannot be started twice');
-
-    window.addEventListener('scroll', this.handleWindowScroll);
-
+  process(chunks: DiffContent[]): GrDiffGroup[] {
+    this.groups = [];
     this.groups.push(this.makeGroup('LOST'));
     this.groups.push(this.makeGroup('FILE'));
 
-    if (this.isBinary) return this.groups;
-    try {
-      await this.processChunks(chunks);
-    } finally {
-      this.finish();
-    }
+    this.processChunks(chunks);
     return this.groups;
   }
 
-  finish() {
-    window.removeEventListener('scroll', this.handleWindowScroll);
-  }
-
-  cancel() {
-    this.isCancelled = true;
-    this.finish();
-  }
-
-  async processChunks(chunks: DiffContent[]) {
-    let completed = () => {};
-    const promise = new Promise<void>(resolve => (completed = resolve));
+  processChunks(chunks: DiffContent[]) {
+    if (this.isBinary) return;
 
     const state = {
       lineNums: {left: 0, right: 0},
       chunkIndex: 0,
     };
-
-    chunks = this.splitLargeChunks(chunks);
     chunks = this.splitCommonChunksWithKeyLocations(chunks);
 
-    let currentBatch = 0;
-    const nextStep = () => {
-      if (this.isCancelled || state.chunkIndex >= chunks.length) {
-        completed();
-        return;
-      }
-      if (this.isScrolling) {
-        window.setTimeout(nextStep, 100);
-        return;
-      }
-
+    while (state.chunkIndex < chunks.length) {
       const stateUpdate = this.processNext(state, chunks);
       for (const group of stateUpdate.groups) {
         this.groups.push(group);
-        currentBatch += group.lines.length;
       }
       state.lineNums.left += stateUpdate.lineDelta.left;
       state.lineNums.right += stateUpdate.lineDelta.right;
-
       state.chunkIndex = stateUpdate.newChunkIndex;
-      if (currentBatch >= this.asyncThreshold) {
-        currentBatch = 0;
-        window.setTimeout(nextStep, 1);
-      } else {
-        nextStep.call(this);
-      }
-    };
-
-    nextStep.call(this);
-    await promise;
+    }
   }
 
   /**
@@ -207,7 +131,7 @@
   processNext(state: State, chunks: DiffContent[]) {
     const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
       chunks,
-      state.chunkIndex
+      state
     );
     if (firstUncollapsibleChunkIndex === state.chunkIndex) {
       const chunk = chunks[state.chunkIndex];
@@ -242,19 +166,78 @@
     return chunk.ab || chunk.b || [];
   }
 
-  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
-    let chunkIndex = offset;
+  private firstUncollapsibleChunkIndex(chunks: DiffContent[], state: State) {
+    let chunkIndex = state.chunkIndex;
+    let offsetLeft = state.lineNums.left;
+    let offsetRight = state.lineNums.right;
     while (
       chunkIndex < chunks.length &&
-      this.isCollapsibleChunk(chunks[chunkIndex])
+      this.isCollapsibleChunk(chunks[chunkIndex], offsetLeft, offsetRight)
     ) {
+      offsetLeft += this.chunkLength(chunks[chunkIndex], Side.LEFT);
+      offsetRight += this.chunkLength(chunks[chunkIndex], Side.RIGHT);
       chunkIndex++;
     }
     return chunkIndex;
   }
 
-  private isCollapsibleChunk(chunk: DiffContent) {
-    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+  /**
+   * Check if a chunk is collapsible.
+   *
+   * A chunk is collapsible if it is either common or skippable, and it is not
+   * a key location, or it is outside of the focus range.
+   *
+   * @param chunk The chunk to check.
+   * @param offsetLeft The offset of the left side of the chunk.
+   * @param offsetRight The offset of the right side of the chunk.
+   * @return True if the chunk is collapsible, false otherwise.
+   */
+  private isCollapsibleChunk(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
+    const isCommonOrSkip = chunk.ab || chunk.common || chunk.skip;
+    const isOutsideOfFocusRange = this.isChunkOutsideOfFocusRange(
+      chunk,
+      offsetLeft,
+      offsetRight
+    );
+    return (isCommonOrSkip && !chunk.keyLocation) || isOutsideOfFocusRange;
+  }
+
+  private isChunkOutsideOfFocusRange(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
+    if (!this.diffRangesToFocus) {
+      return false;
+    }
+    const leftLineCount = this.linesLeft(chunk).length;
+    const rightLineCount = this.linesRight(chunk).length;
+    const hasLeftSideOverlap = this.diffRangesToFocus.left.some(range =>
+      this.hasAnyOverlap(
+        {start: offsetLeft, end: offsetLeft + leftLineCount},
+        range
+      )
+    );
+    const hasRightSideOverlap = this.diffRangesToFocus.right.some(range =>
+      this.hasAnyOverlap(
+        {start: offsetRight, end: offsetRight + rightLineCount},
+        range
+      )
+    );
+    return !hasLeftSideOverlap && !hasRightSideOverlap;
+  }
+
+  private hasAnyOverlap(
+    firstRange: {start: number; end: number},
+    secondRange: {start: number; end: number}
+  ) {
+    const startOverlap = Math.max(firstRange.start, secondRange.start);
+    const endOverlap = Math.min(firstRange.end, secondRange.end);
+    return startOverlap <= endOverlap;
   }
 
   /**
@@ -276,8 +259,12 @@
       state.chunkIndex,
       firstUncollapsibleChunkIndex
     );
-    const lineCount = collapsibleChunks.reduce(
-      (sum, chunk) => sum + this.commonChunkLength(chunk),
+    const leftLineCount = collapsibleChunks.reduce(
+      (sum, chunk) => sum + this.chunkLength(chunk, Side.LEFT),
+      0
+    );
+    const rightLineCount = collapsibleChunks.reduce(
+      (sum, chunk) => sum + this.chunkLength(chunk, Side.RIGHT),
       0
     );
 
@@ -288,25 +275,50 @@
     );
 
     const hasSkippedGroup = !!groups.find(g => g.skip);
-    if (this.context !== FULL_CONTEXT || hasSkippedGroup) {
+    const hasNonCommonDeltaGroup = !!groups.find(
+      g => g.type === GrDiffGroupType.DELTA && !g.ignoredWhitespaceOnly
+    );
+    if (
+      this.context !== FULL_CONTEXT ||
+      hasSkippedGroup ||
+      hasNonCommonDeltaGroup
+    ) {
       const contextNumLines = this.context > 0 ? this.context : 0;
       const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
-      const hiddenEnd =
-        lineCount -
+      const hiddenEndLeft =
+        leftLineCount -
         (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
-      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+      const hiddenEndRight =
+        rightLineCount -
+        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+      groups = hideInContextControl(
+        groups,
+        hiddenStart,
+        hiddenEndLeft,
+        hiddenEndRight
+      );
     }
 
     return {
       lineDelta: {
-        left: lineCount,
-        right: lineCount,
+        left: leftLineCount,
+        right: rightLineCount,
       },
       groups,
       newChunkIndex: firstUncollapsibleChunkIndex,
     };
   }
 
+  private chunkLength(chunk: DiffContent, side: Side) {
+    if (chunk.skip || chunk.common || chunk.ab) {
+      return this.commonChunkLength(chunk);
+    } else if (side === Side.LEFT) {
+      return this.linesLeft(chunk).length;
+    } else {
+      return this.linesRight(chunk).length;
+    }
+  }
+
   private commonChunkLength(chunk: DiffContent) {
     if (chunk.skip) {
       return chunk.skip;
@@ -328,9 +340,8 @@
   ): GrDiffGroup[] {
     return chunks.map(chunk => {
       const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
-      const chunkLength = this.commonChunkLength(chunk);
-      offsetLeft += chunkLength;
-      offsetRight += chunkLength;
+      offsetLeft += this.chunkLength(chunk, Side.LEFT);
+      offsetRight += this.chunkLength(chunk, Side.RIGHT);
       return group;
     });
   }
@@ -448,53 +459,6 @@
   }
 
   /**
-   * Split chunks into smaller chunks of the same kind.
-   *
-   * This is done to prevent doing too much work on the main thread in one
-   * uninterrupted rendering step, which would make the browser unresponsive.
-   *
-   * Note that in the case of unmodified chunks, we only split chunks if the
-   * context is set to file (because otherwise they are split up further down
-   * the processing into the visible and hidden context), and only split it
-   * into 2 chunks, one max sized one and the rest (for reasons that are
-   * unclear to me).
-   *
-   * @param chunks Chunks as returned from the server
-   * @return Finer grained chunks.
-   */
-  // visible for testing
-  splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
-    const newChunks = [];
-
-    for (const chunk of chunks) {
-      if (!chunk.ab) {
-        for (const subChunk of this.breakdownChunk(chunk)) {
-          newChunks.push(subChunk);
-        }
-        continue;
-      }
-
-      // If the context is set to "whole file", then break down the shared
-      // chunks so they can be rendered incrementally. Note: this is not
-      // enabled for any other context preference because manipulating the
-      // chunks in this way violates assumptions by the context grouper logic.
-      const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
-      if (
-        this.context === FULL_CONTEXT &&
-        chunk.ab.length > MAX_GROUP_SIZE * 2
-      ) {
-        // Split large shared chunks in two, where the first is the maximum
-        // group size.
-        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
-        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
-      } else {
-        newChunks.push(chunk);
-      }
-    }
-    return newChunks;
-  }
-
-  /**
    * In order to show key locations, such as comments, out of the bounds of
    * the selected context, treat them as separate chunks within the model so
    * that the content (and context surrounding it) renders correctly.
@@ -675,60 +639,4 @@
     }
     return normalized;
   }
-
-  /**
-   * If a group is an addition or a removal, break it down into smaller groups
-   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
-   * or a delta it is returned as the single element of the result array.
-   */
-  // visible for testing
-  breakdownChunk(chunk: DiffContent): DiffContent[] {
-    let key: 'a' | 'b' | 'ab' | null = null;
-    const {a, b, ab, move_details} = chunk;
-    if (a?.length && !b?.length) {
-      key = 'a';
-    } else if (b?.length && !a?.length) {
-      key = 'b';
-    } else if (ab?.length) {
-      key = 'ab';
-    }
-
-    // Move chunks should not be divided because of move label
-    // positioned in the top of the chunk
-    if (!key || move_details) {
-      return [chunk];
-    }
-
-    const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
-    return this.breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
-      const subChunk: DiffContent = {};
-      subChunk[key!] = subChunkLines;
-      if (chunk.due_to_rebase) {
-        subChunk.due_to_rebase = true;
-      }
-      if (chunk.move_details) {
-        subChunk.move_details = chunk.move_details;
-      }
-      return subChunk;
-    });
-  }
-
-  /**
-   * Given an array and a size, return an array of arrays where no inner array
-   * is larger than that size, preserving the original order.
-   */
-  // visible for testing
-  breakdown<T>(array: T[], size: number): T[][] {
-    if (!array.length) {
-      return [];
-    }
-    if (array.length < size) {
-      return [array];
-    }
-
-    const head = array.slice(0, array.length - size);
-    const tail = array.slice(array.length - size);
-
-    return this.breakdown(head, size).concat([tail]);
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 3485fe4..19a687e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -578,71 +578,6 @@
       ]);
     });
 
-    test('breaks down shared chunks w/ whole-file', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const ab = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      const content = [{ab}];
-      processor.context = FULL_CONTEXT;
-      const result = processor.splitLargeChunks(content);
-      assert.equal(result.length, 2);
-      assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
-      assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
-    });
-
-    test('breaks down added chunks', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const content = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      processor.context = 5;
-      const splitContent = processor
-        .splitLargeChunks([{a: [], b: content}])
-        .map(r => r.b);
-      assert.equal(splitContent.length, 3);
-      assert.deepEqual(splitContent[0], content.slice(0, 5));
-      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
-      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
-    });
-
-    test('breaks down removed chunks', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const content = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      processor.context = 5;
-      const splitContent = processor
-        .splitLargeChunks([{a: content, b: []}])
-        .map(r => r.a);
-      assert.equal(splitContent.length, 3);
-      assert.deepEqual(splitContent[0], content.slice(0, 5));
-      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
-      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
-    });
-
-    test('does not break down moved chunks', () => {
-      const size = 120 * 2 + 5;
-      const content = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      processor.context = 5;
-      const splitContent = processor
-        .splitLargeChunks([
-          {
-            a: content,
-            b: [],
-            move_details: {changed: false, range: {start: 1, end: 1}},
-          },
-        ])
-        .map(r => r.a);
-      assert.equal(splitContent.length, 1);
-      assert.deepEqual(splitContent[0], content);
-    });
-
     test('does not break-down common chunks w/ context', () => {
       const ab = Array(75)
         .fill(0)
@@ -767,15 +702,6 @@
       ]);
     });
 
-    test('isScrolling paused', async () => {
-      const content = Array(200).fill({ab: ['', '']});
-      processor.isScrolling = true;
-      const promise = processor.process(content);
-      processor.isScrolling = false;
-      const groups = await promise;
-      assert.isAtLeast(groups.length, 3);
-    });
-
     test('image diffs', async () => {
       const content = Array(200).fill({ab: ['', '']});
       options.isBinary = true;
@@ -1007,6 +933,154 @@
           );
         });
       });
+
+      suite('with diffRangesToFocus', () => {
+        let state: State;
+        let chunks: DiffContent[];
+
+        setup(() => {
+          state = {
+            lineNums: {left: 0, right: 0},
+            chunkIndex: 0,
+          };
+          processor.context = 3;
+          processor.diffRangesToFocus = {
+            left: [{start: 6, end: 7}],
+            right: [{start: 6, end: 6}],
+          };
+          chunks = [
+            {
+              ab: Array.from<string>({length: 5}).fill(
+                'all work and no play make jill a dull boy'
+              ),
+            },
+            {
+              a: ['Old ', ' Change!'],
+              b: ['New Change'],
+            },
+            {
+              ab: Array.from<string>({length: 5}).fill(
+                'all work and no play make jack a dull boy'
+              ),
+            },
+            {
+              a: ['Old ', ' Change!', '1'],
+              b: ['New Change', '2'],
+            },
+          ];
+        });
+
+        test('focussed group is not collapsed in context control group', () => {
+          const result = processor.processNext(state, chunks);
+
+          // This should consider second delta group as focussed and not collapse it.
+          // This result is first chunk itself.
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 5);
+        });
+
+        test('collapsing delta group at end in context control group', () => {
+          state = {
+            lineNums: {left: 7, right: 6},
+            chunkIndex: 2,
+          };
+          const result = processor.processNext(state, [
+            ...chunks,
+            {
+              ab: Array.from<string>({length: 5}).fill(
+                'all work and no play make jack a dull boy'
+              ),
+            },
+          ]);
+
+          // The first chunk is split into two groups:
+          // 1) A common group which is rendered before contextControl group
+          // 2) Second group is a context control which contains split from 4th chunk
+          // and the delta group and the last unchanged group.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 3);
+          assert.equal(result.groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.equal(result.groups[1].contextGroups.length, 3);
+          assert.equal(result.groups[1].contextGroups[0].lines.length, 2);
+          assert.equal(
+            result.groups[1].contextGroups[1].type,
+            GrDiffGroupType.DELTA
+          );
+          assert.equal(
+            result.groups[1].contextGroups[2].type,
+            GrDiffGroupType.BOTH
+          );
+        });
+
+        test('collapsing delta group in middle in context control group', () => {
+          state = {
+            lineNums: {left: 7, right: 6},
+            chunkIndex: 2,
+          };
+          const result = processor.processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A common group which is rendered before contextControl group
+          // 2) Second group is a context control which contains split from 4th chunk
+          // and the delta group.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 3);
+          assert.equal(result.groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.equal(result.groups[1].contextGroups.length, 2);
+          assert.equal(result.groups[1].contextGroups[0].lines.length, 2);
+          assert.equal(
+            result.groups[1].contextGroups[1].type,
+            GrDiffGroupType.DELTA
+          );
+        });
+
+        test('do not collapse if there are not enough context lines', () => {
+          processor.context = 10;
+          state = {
+            lineNums: {left: 7, right: 6},
+            chunkIndex: 2,
+          };
+          const result = processor.processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A common group which is rendered before contextControl group
+          // 2) Second group is a context control which contains split from 4th chunk
+          // and the delta group.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 5);
+          assert.equal(result.groups[1].type, GrDiffGroupType.DELTA);
+        });
+
+        test('collapse chunks with key locations if out of focus range', () => {
+          const keyLocationLineText = 'key location behind a context group';
+          state = {
+            lineNums: {left: 7, right: 6},
+            chunkIndex: 4,
+          };
+          const result = processor.processNext(state, [
+            ...chunks,
+            {
+              ab: Array.from<string>({length: 2}).fill(
+                'all work and no play make jill a dull boy'
+              ),
+              keyLocation: false,
+            },
+            {
+              ab: Array.from<string>({length: 5}).fill(keyLocationLineText),
+              keyLocation: true,
+            },
+          ]);
+          assert.equal(result.groups.length, 3);
+          assert.equal(
+            result.groups[2].contextGroups[0].lines[0].text,
+            keyLocationLineText
+          );
+        });
+      });
     });
 
     suite('gr-diff-processor helpers', () => {
@@ -1053,61 +1127,5 @@
         assert.notOk(result[result.length - 1].afterNumber);
       });
     });
-
-    suite('breakdown*', () => {
-      test('breakdownChunk breaks down additions', () => {
-        const breakdownSpy = sinon.spy(processor, 'breakdown');
-        const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = processor.breakdownChunk(chunk);
-        assert.deepEqual(result, [chunk]);
-        assert.isTrue(breakdownSpy.called);
-      });
-
-      test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
-        sinon.spy(processor, 'breakdown');
-        const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-        const result = processor.breakdownChunk(chunk);
-        for (const subResult of result) {
-          assert.isTrue(subResult.due_to_rebase);
-        }
-      });
-
-      test('breakdown common case', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
-          ' '
-        );
-        const size = 3;
-
-        const result = processor.breakdown(array, size);
-
-        for (const subResult of result) {
-          assert.isAtMost(subResult.length, size);
-        }
-        const flattened = result.reduce((a, b) => a.concat(b), []);
-        assert.deepEqual(flattened, array);
-      });
-
-      test('breakdown smaller than size', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
-          ' '
-        );
-        const size = 10;
-        const expected = [array];
-
-        const result = processor.breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-
-      test('breakdown empty', () => {
-        const array: string[] = [];
-        const size = 10;
-        const expected: string[][] = [];
-
-        const result = processor.breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-    });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index 9cc6a90..dd39aa5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-diff-selection';
 import '../gr-diff/gr-diff';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
index e158bd7..eb04f57 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
@@ -24,7 +24,7 @@
 } from '../../../constants/constants';
 import {fire} from '../../../utils/event-util';
 import {RenderPreferences, LOST, DiffResponsiveMode} from '../../../api/diff';
-import {query, queryAll, state} from 'lit/decorators.js';
+import {property, query, queryAll, state} from 'lit/decorators.js';
 import {html, LitElement, nothing} from 'lit';
 import {when} from 'lit/directives/when.js';
 import {classMap} from 'lit/directives/class-map.js';
@@ -76,6 +76,10 @@
 
   @state() columnCount = 0;
 
+  // Extra message shown if files are binary to help users investigate contents.
+  @property({type: String})
+  binaryDiffHint = '';
+
   private getDiffModel = resolve(this, diffModelToken);
 
   /**
@@ -362,7 +366,7 @@
       <tbody class="binary-diff">
         <tr>
           <td colspan=${this.columnCount}>
-            <span>Difference in binary files</span>
+            <span>Difference in binary files.${this.binaryDiffHint}</span>
           </td>
         </tr>
       </tbody>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
index 1a43719..c9f4882 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
@@ -2804,7 +2804,7 @@
                 <tbody class="binary-diff">
                   <tr>
                     <td colspan="4">
-                      <span> Difference in binary files </span>
+                      <span> Difference in binary files. </span>
                     </td>
                   </tr>
                 </tbody>
@@ -3366,11 +3366,12 @@
 
     test('binary', async () => {
       element.diff = {...createEmptyDiff(), content, binary: true};
+      element.binaryDiffHint = ' Download commit to view (shortcut: d)';
       await element.updateComplete;
       const body = queryAndAssert(element, 'tbody.binary-diff');
       assert.lightDom.equal(
         body,
-        /* HTML */ '<span>Difference in binary files</span>'
+        /* HTML */ '<span>Difference in binary files. Download commit to view (shortcut: d)</span>'
       );
     });
   });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index c5366e7..e7df002 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -42,40 +42,56 @@
  * @param hiddenStart The first element to be hidden, as a
  *     non-negative line number offset relative to the first group's start
  *     line, left and right respectively.
- * @param hiddenEnd The first visible element after the hidden range,
- *     as a non-negative line number offset relative to the first group's
- *     start line, left and right respectively.
+ * @param hiddenEndLeft The first visible element after the hidden range,
+ *     as a non-negative line number offset for left side relative to the first
+ *     group's start line.
+ * @param hiddenEndRight The first visible element after the hidden range,
+ *     as a non-negative line number offset for right side relative to the first
+ *     group's start line. If not provided hiddenEndLeft will be used.
  */
 export function hideInContextControl(
   groups: readonly GrDiffGroup[],
   hiddenStart: number,
-  hiddenEnd: number
+  hiddenEndLeft: number,
+  hiddenEndRight?: number
 ): GrDiffGroup[] {
   if (groups.length === 0) return [];
   // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
   hiddenStart = Math.max(hiddenStart, 0);
-  hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+  hiddenEndLeft = Math.max(hiddenEndLeft, hiddenStart);
+  hiddenEndRight = Math.max(hiddenEndRight ?? hiddenEndLeft, hiddenStart);
 
   let before: GrDiffGroup[] = [];
   let hidden = groups;
   let after: readonly GrDiffGroup[] = [];
 
-  const numHidden = hiddenEnd - hiddenStart;
+  const numHiddenLeft = hiddenEndLeft - hiddenStart;
+  const numHiddenRight = hiddenEndRight - hiddenStart;
 
   // Showing a context control row for less than 4 lines does not make much,
   // because then that row would consume as much space as the collapsed code.
-  if (numHidden > 3) {
+  if (numHiddenLeft > 3 && numHiddenRight > 3) {
     if (hiddenStart) {
-      [before, hidden] = splitCommonGroups(hidden, hiddenStart);
+      [before, hidden] = splitCommonGroups(hidden, hiddenStart, hiddenStart);
     }
-    if (hiddenEnd) {
-      let beforeLength = 0;
+    if (hiddenEndLeft && hiddenEndRight) {
+      let beforeLengthLeft = 0;
+      let beforeLengthRight = 0;
       if (before.length > 0) {
-        const beforeStart = before[0].lineRange.left.start_line;
-        const beforeEnd = before[before.length - 1].lineRange.left.end_line;
-        beforeLength = beforeEnd - beforeStart + 1;
+        const beforeStartLeft = before[0].lineRange.left.start_line;
+        const beforeEndLeft = before[before.length - 1].lineRange.left.end_line;
+        beforeLengthLeft = beforeEndLeft - beforeStartLeft + 1;
+
+        const beforeStartRight = before[0].lineRange.right.start_line;
+        const beforeEndRight =
+          before[before.length - 1].lineRange.right.end_line;
+        beforeLengthRight = beforeEndRight - beforeStartRight + 1;
       }
-      [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
+      [hidden, after] = splitCommonGroups(
+        hidden,
+        hiddenEndLeft - beforeLengthLeft,
+        hiddenEndRight - beforeLengthRight
+      );
     }
   } else {
     [hidden, after] = [[], hidden];
@@ -165,45 +181,59 @@
  * Groups where all lines are before or all lines are after the split will be
  * retained as is and put into the first or second list respectively. Groups
  * with some lines before and some lines after the split will be split into
- * two groups, which will be put into the first and second list.
+ * two groups, which will be put into the first and second list. Groups with
+ * type DELTA which are not common will not be split.
  *
- * @param split A line number offset relative to the first group's
- *     start line at which the groups should be split.
+ * @param splitLeft A line number offset for left side relative to the first
+ *     group's start line at which the groups should be split.
+ * @param splitRight A line number offset for right side relative to the first
+ *     group's start line at which the groups should be split.
  * @return The outer array has 2 elements, the
  *   list of groups before and the list of groups after the split.
  */
 function splitCommonGroups(
   groups: readonly GrDiffGroup[],
-  split: number
+  splitLeft: number,
+  splitRight: number
 ): GrDiffGroup[][] {
   if (groups.length === 0) return [[], []];
-  const leftSplit = groups[0].lineRange.left.start_line + split;
-  const rightSplit = groups[0].lineRange.right.start_line + split;
-
+  const leftSplit = groups[0].lineRange.left.start_line + splitLeft;
+  const rightSplit = groups[0].lineRange.right.start_line + splitRight;
+  let isSplitDone = false;
   const beforeGroups = [];
   const afterGroups = [];
   for (const group of groups) {
-    const isCompletelyBefore =
-      group.lineRange.left.end_line < leftSplit ||
-      group.lineRange.right.end_line < rightSplit;
-    const isCompletelyAfter =
-      leftSplit <= group.lineRange.left.start_line ||
-      rightSplit <= group.lineRange.right.start_line;
-    if (isCompletelyBefore) {
-      beforeGroups.push(group);
-    } else if (isCompletelyAfter) {
+    if (isSplitDone) {
       afterGroups.push(group);
+    } else if (
+      group.type === GrDiffGroupType.DELTA &&
+      !group.ignoredWhitespaceOnly
+    ) {
+      beforeGroups.push(group);
     } else {
-      const {beforeSplit, afterSplit} = splitGroupInTwo(
-        group,
-        leftSplit,
-        rightSplit
-      );
-      if (beforeSplit) {
-        beforeGroups.push(beforeSplit);
-      }
-      if (afterSplit) {
-        afterGroups.push(afterSplit);
+      const isCompletelyBefore =
+        group.lineRange.left.end_line < leftSplit ||
+        group.lineRange.right.end_line < rightSplit;
+      const isCompletelyAfter =
+        leftSplit <= group.lineRange.left.start_line ||
+        rightSplit <= group.lineRange.right.start_line;
+      if (isCompletelyBefore) {
+        beforeGroups.push(group);
+      } else if (isCompletelyAfter) {
+        afterGroups.push(group);
+      } else {
+        const {beforeSplit, afterSplit} = splitGroupInTwo(
+          group,
+          leftSplit,
+          rightSplit
+        );
+        if (beforeSplit) {
+          beforeGroups.push(beforeSplit);
+        }
+        if (afterSplit) {
+          afterGroups.push(afterSplit);
+        }
+        isSplitDone = true;
       }
     }
   }
@@ -438,6 +468,15 @@
     );
   }
 
+  /** Returns true if it contains a DELTA group excluding whitespace only
+   * changes.
+   */
+  hasNonCommonDeltaGroup() {
+    return this.contextGroups?.some(
+      g => g.type === GrDiffGroupType.DELTA && !g.ignoredWhitespaceOnly
+    );
+  }
+
   containsLine(side: Side, line: LineNumber) {
     if (typeof line !== 'number') {
       // For FILE and LOST, beforeNumber and afterNumber are the same
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index bbbb4ad..c8446f0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -97,6 +97,8 @@
 
   suite('hideInContextControl', () => {
     let groups: GrDiffGroup[];
+    let groupsWithDelta: GrDiffGroup[];
+    let groupsWithWhiteSpaceOnlyChange: GrDiffGroup[];
     setup(() => {
       groups = [
         new GrDiffGroup({
@@ -108,6 +110,46 @@
           ],
         }),
         new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 8, 10),
+            new GrDiffLine(GrDiffLineType.BOTH, 9, 11),
+            new GrDiffLine(GrDiffLineType.BOTH, 10, 12),
+            new GrDiffLine(GrDiffLineType.BOTH, 11, 13),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+          ],
+        }),
+      ];
+
+      groupsWithWhiteSpaceOnlyChange = [
+        groups[0],
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+            new GrDiffLine(GrDiffLineType.REMOVE, 10),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+            new GrDiffLine(GrDiffLineType.REMOVE, 11),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+          ],
+          ignoredWhitespaceOnly: true,
+        }),
+        groups[2],
+      ];
+
+      groupsWithDelta = [
+        groups[0],
+        new GrDiffGroup({
           type: GrDiffGroupType.DELTA,
           lines: [
             new GrDiffLine(GrDiffLineType.REMOVE, 8),
@@ -120,14 +162,7 @@
             new GrDiffLine(GrDiffLineType.ADD, 0, 13),
           ],
         }),
-        new GrDiffGroup({
-          type: GrDiffGroupType.BOTH,
-          lines: [
-            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
-            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
-            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
-          ],
-        }),
+        groups[2],
       ];
     });
 
@@ -144,29 +179,33 @@
       assert.equal(collapsedGroups[2], groups[2]);
     });
 
+    test('does not hides when split is at delta group in context control', () => {
+      const collapsedGroups = hideInContextControl(groupsWithDelta, 3, 7);
+      assert.equal(collapsedGroups.length, 3);
+
+      assert.equal(collapsedGroups[0], groupsWithDelta[0]);
+      assert.equal(collapsedGroups[1], groupsWithDelta[1]);
+      assert.equal(collapsedGroups[2], groupsWithDelta[2]);
+    });
+
     test('splits partially hidden groups', () => {
       const collapsedGroups = hideInContextControl(groups, 4, 8);
       assert.equal(collapsedGroups.length, 4);
       assert.equal(collapsedGroups[0], groups[0]);
 
-      assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
-      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
-      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.BOTH);
+      assert.deepEqual(collapsedGroups[1].lines, [groups[1].lines[0]]);
 
       assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
       assert.equal(collapsedGroups[2].contextGroups.length, 2);
 
       assert.equal(
         collapsedGroups[2].contextGroups[0].type,
-        GrDiffGroupType.DELTA
+        GrDiffGroupType.BOTH
       );
       assert.deepEqual(
-        collapsedGroups[2].contextGroups[0].adds,
-        groups[1].adds.slice(1)
-      );
-      assert.deepEqual(
-        collapsedGroups[2].contextGroups[0].removes,
-        groups[1].removes.slice(1)
+        collapsedGroups[2].contextGroups[0].lines,
+        groups[1].lines.slice(1)
       );
 
       assert.equal(
@@ -181,6 +220,54 @@
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
     });
 
+    test('splits partially hidden common delta groups', () => {
+      const collapsedGroups = hideInContextControl(
+        groupsWithWhiteSpaceOnlyChange,
+        4,
+        8
+      );
+      assert.equal(collapsedGroups.length, 4);
+      assert.equal(collapsedGroups[0], groupsWithWhiteSpaceOnlyChange[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
+      assert.deepEqual(collapsedGroups[1].adds, [
+        groupsWithWhiteSpaceOnlyChange[1].adds[0],
+      ]);
+      assert.deepEqual(collapsedGroups[1].removes, [
+        groupsWithWhiteSpaceOnlyChange[1].removes[0],
+      ]);
+
+      assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[2].contextGroups.length, 2);
+
+      assert.equal(
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
+      assert.deepEqual(
+        collapsedGroups[2].contextGroups[0].adds,
+        groupsWithWhiteSpaceOnlyChange[1].adds.slice(1)
+      );
+      assert.deepEqual(
+        collapsedGroups[2].contextGroups[0].removes,
+        groupsWithWhiteSpaceOnlyChange[1].removes.slice(1)
+      );
+
+      assert.equal(
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groupsWithWhiteSpaceOnlyChange[2].lines[0],
+      ]);
+
+      assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
+      assert.deepEqual(
+        collapsedGroups[3].lines,
+        groupsWithWhiteSpaceOnlyChange[2].lines.slice(1)
+      );
+    });
+
     suite('with skip chunks', () => {
       setup(() => {
         const skipGroup = new GrDiffGroup({
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
index 19a6457..2913fc8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -75,6 +75,8 @@
   gr-diff-element {
     /* for gr-selection-action-box positioning */
     position: relative;
+    /* Firefox requires a block to position child elements absolutely */
+    display: block;
   }
   gr-diff-element gr-selection-action-box {
     /* Needs z-index to appear above wrapped content, since it's inserted
@@ -365,10 +367,22 @@
   gr-diff-row td.sign.add.no-intraline-info,
   gr-diff-section tbody.delta.total gr-diff-row td.content.add div.contentText {
     background-color: var(--dark-add-highlight-color);
+    &:has(.is-out-of-focus-range) {
+      background-color: transparent;
+      .intraline {
+        background-color: transparent;
+      }
+    }
   }
   gr-diff-row td.content.add div.contentText,
   gr-diff-row td.sign.add {
     background-color: var(--light-add-highlight-color);
+    &:has(.is-out-of-focus-range) {
+      background-color: transparent;
+      .intraline {
+        background-color: transparent;
+      }
+    }
   }
   /* If there are no intraline info, consider everything changed */
   gr-diff-row td.content.remove div.contentText .intraline,
@@ -380,10 +394,16 @@
     div.contentText,
   gr-diff-row td.sign.remove.no-intraline-info {
     background-color: var(--dark-remove-highlight-color);
+    &:has(.is-out-of-focus-range) {
+      background-color: transparent;
+    }
   }
   gr-diff-row td.content.remove div.contentText,
   gr-diff-row td.sign.remove {
     background-color: var(--light-remove-highlight-color);
+    &:has(.is-out-of-focus-range) {
+      background-color: transparent;
+    }
   }
   gr-diff-element table.responsive gr-diff-row td.content div.contentText {
     white-space: break-spaces;
@@ -582,7 +602,8 @@
   gr-diff-row tr.diff-row,
   gr-diff-row td.content,
   gr-diff-section tbody.contextControl,
-  gr-diff-row td.blame {
+  gr-diff-row td.blame,
+  #diffHeader {
     -webkit-user-select: none;
     -moz-user-select: none;
     -ms-user-select: none;
@@ -667,9 +688,17 @@
 
 // Styles related to the <gr-diff-text> component.
 export const grDiffTextStyles = css`
-  gr-diff-text .token-highlight {
+  /* The background color for tokens of the "token-highlight-layer". */
+  gr-diff-text hl.token-highlight {
     background-color: var(--token-highlighting-color, #fffd54);
   }
+  /* We do not want token highlighting to override the "rangeHighlight"
+    color, so let's make sure that there are no "rangeHighlight" element
+    parents that wrap the "token-highlight" element.
+  */
+  gr-diff-text hl.rangeHighlight hl.token-highlight {
+    background-color: transparent;
+  }
   /* Describes two states of semantic tokens: whenever a token has a
      definition that can be navigated to (navigable) and whenever
      the token is actually clickable to perform this navigation. */
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 90b9cfe..07aaf55 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -44,6 +44,7 @@
   RenderPreferences,
   GrDiff as GrDiffApi,
   DisplayLine,
+  DiffRangesToFocus,
   LineNumber,
   ContentLoadNeededEventDetail,
   DiffContextExpandedExternalDetail,
@@ -74,6 +75,7 @@
   grDiffTextStyles,
 } from './gr-diff-styles';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {GrFocusLayer} from '../gr-focus-layer/gr-focus-layer';
 import {
   GrAnnotationImpl,
   getStringLength,
@@ -182,6 +184,13 @@
   @property({type: Object})
   lineOfInterest?: DisplayLine;
 
+  @property({type: Object})
+  diffRangesToFocus?: DiffRangesToFocus;
+
+  // Extra message shown if files are binary to help users investigate contents.
+  @property({type: String})
+  binaryDiffHint = '';
+
   /**
    * True when diff is changed, until the content is done rendering.
    * Use getter/setter loading instead of this.
@@ -271,6 +280,8 @@
 
   private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
 
+  private focusLayer = new GrFocusLayer();
+
   private rangeLayer = new GrRangedCommentLayer();
 
   @state() groups: GrDiffGroup[] = [];
@@ -368,7 +379,8 @@
       changedProperties.has('showNewlineWarningLeft') ||
       changedProperties.has('showNewlineWarningRight') ||
       changedProperties.has('prefs') ||
-      changedProperties.has('lineOfInterest')
+      changedProperties.has('lineOfInterest') ||
+      changedProperties.has('diffRangesToFocus')
     ) {
       if (this.diff && this.prefs) {
         const renderPrefs = {...(this.renderPrefs ?? {})};
@@ -394,6 +406,7 @@
           renderPrefs,
           diffPrefs: this.prefs,
           lineOfInterest: this.lineOfInterest,
+          diffRangesToFocus: this.diffRangesToFocus,
         });
       }
     }
@@ -434,6 +447,9 @@
     if (changedProperties.has('lineOfInterest')) {
       this.lineOfInterestChanged();
     }
+    if (changedProperties.has('diffRangesToFocus')) {
+      this.updateFocusRanges(this.diffRangesToFocus);
+    }
   }
 
   protected override async getUpdateComplete(): Promise<boolean> {
@@ -455,7 +471,9 @@
   }
 
   override render() {
-    return html`<gr-diff-element></gr-diff-element>`;
+    return html`<gr-diff-element
+      .binaryDiffHint=${this.binaryDiffHint}
+    ></gr-diff-element>`;
   }
 
   private addSelectionListeners() {
@@ -714,6 +732,10 @@
     this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
   }
 
+  private updateFocusRanges(rs?: DiffRangesToFocus) {
+    this.focusLayer.setRanges(rs);
+  }
+
   private onDiffContextExpanded = (
     e: CustomEvent<DiffContextExpandedEventDetail>
   ) => {
@@ -740,6 +762,7 @@
       this.rangeLayer,
       this.coverageLayerLeft,
       this.coverageLayerRight,
+      this.focusLayer,
     ];
     this.layersChanged();
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 5fa4788..ffb5c90 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {
   createConfig,
diff --git a/polygerrit-ui/app/embed/diff/gr-focus-layer/gr-focus-layer.ts b/polygerrit-ui/app/embed/diff/gr-focus-layer/gr-focus-layer.ts
new file mode 100644
index 0000000..6d55b0f
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-focus-layer/gr-focus-layer.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {DiffRangesToFocus, GrDiffLine, Side} from '../../../api/diff';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
+
+// A range of lines in a diff.
+export type Range = {
+  start: number;
+  end: number;
+};
+
+export class GrFocusLayer implements DiffLayer {
+  private diffRangesToFocus?: DiffRangesToFocus;
+
+  /**
+   * Diff Ranges which were unfocused(colors are saturated) in previous call.
+   */
+  private previousUnfocusedRanges?: DiffRangesToFocus;
+
+  /**
+   * Has any line been annotated already in the lifetime of this layer?
+   * If not, then `setRanges()` does not have to call `notify()` and thus
+   * trigger re-rendering of the affected diff rows.
+   */
+  // visible for testing
+  annotated = false;
+
+  private listeners: DiffLayerListener[] = [];
+
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  setRanges(diffRangesToFocus?: DiffRangesToFocus) {
+    if (!this.previousUnfocusedRanges && !diffRangesToFocus) return;
+    this.diffRangesToFocus = diffRangesToFocus;
+
+    // If ranges are set before any diff row was rendered, then great, no need
+    // to notify and re-render.
+    if (this.annotated) {
+      this.notify({
+        left: [
+          ...(this.previousUnfocusedRanges?.left ?? []),
+          ...(diffRangesToFocus?.left ?? []),
+        ],
+        right: [
+          ...(this.previousUnfocusedRanges?.right ?? []),
+          ...(diffRangesToFocus?.right ?? []),
+        ],
+      });
+    }
+    this.previousUnfocusedRanges = undefined;
+  }
+
+  private notify(ranges: DiffRangesToFocus) {
+    for (const r of ranges.left) {
+      for (const l of this.listeners) l(r.start, r.end, Side.LEFT);
+    }
+    for (const r of ranges.right) {
+      for (const l of this.listeners) l(r.start, r.end, Side.RIGHT);
+    }
+  }
+
+  /**
+   * Layer method to add is-out-of-focus-range to a textElement
+   * if line is out of focus.
+   *
+   * @param textEl The gr-text element for this line.
+   * @param lineNumberEl The <td> element with the line number.
+   * @param _line Not used for this layer. (unused parameter)
+   * @param side The side of the diff.
+   */
+  annotate(
+    textEl: HTMLElement,
+    lineNumberEl: HTMLElement,
+    _line: GrDiffLine,
+    side: Side
+  ) {
+    this.annotated = true;
+    if (!lineNumberEl || !textEl || !this.diffRangesToFocus) {
+      return;
+    }
+    let elementLineNumber = -1;
+    const dataValue = lineNumberEl.getAttribute('data-value');
+    if (dataValue) {
+      elementLineNumber = Number(dataValue);
+    }
+    if (!elementLineNumber || elementLineNumber < 1) return;
+
+    let focusedRanges: Range[] = [];
+    if (side === Side.LEFT) {
+      focusedRanges = this.diffRangesToFocus.left;
+    } else if (side === Side.RIGHT) {
+      focusedRanges = this.diffRangesToFocus.right;
+    }
+    // TODO(anuragpathak): Optimize this using the same approach as gr-coverage-layer.ts
+    if (
+      !focusedRanges.some(
+        range =>
+          elementLineNumber >= range.start && elementLineNumber <= range.end
+      )
+    ) {
+      textEl.classList.add('is-out-of-focus-range');
+      this.updateUnfocusedRanges(elementLineNumber, side);
+    }
+  }
+
+  private updateUnfocusedRanges(lineNumber: number, side: Side) {
+    this.previousUnfocusedRanges = {
+      left:
+        side === Side.LEFT
+          ? this.addToRange(lineNumber, this.previousUnfocusedRanges?.left)
+          : this.previousUnfocusedRanges?.left ?? [],
+      right:
+        side === Side.RIGHT
+          ? this.addToRange(lineNumber, this.previousUnfocusedRanges?.right)
+          : this.previousUnfocusedRanges?.right ?? [],
+    };
+  }
+
+  private addToRange(lineNumber: number, ranges?: Range[]) {
+    const previousRange: Range[] = [];
+    if (ranges) {
+      previousRange.push(...ranges);
+    }
+    let lastEntryInRange = previousRange.pop();
+    if (lastEntryInRange) {
+      if (lastEntryInRange.end + 1 === lineNumber) {
+        lastEntryInRange = {start: lastEntryInRange.start, end: lineNumber};
+        previousRange.push(lastEntryInRange);
+      } else {
+        previousRange.push(lastEntryInRange, {
+          start: lineNumber,
+          end: lineNumber,
+        });
+      }
+    } else {
+      previousRange.push({
+        start: lineNumber,
+        end: lineNumber,
+      });
+    }
+    return previousRange;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-focus-layer/gr-focus-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-focus-layer/gr-focus-layer_test.ts
new file mode 100644
index 0000000..9687ee8
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-focus-layer/gr-focus-layer_test.ts
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import * as sinon from 'sinon';
+import '../../../test/common-test-setup';
+
+import {assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+
+import {DiffRangesToFocus, Side, GrDiffLineType} from '../../../api/diff';
+
+import {GrFocusLayer} from './gr-focus-layer';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+
+const ONE_RANGE: DiffRangesToFocus = {
+  left: [{start: 1, end: 2}],
+  right: [{start: 9, end: 10}],
+};
+
+const RANGES: DiffRangesToFocus = {
+  left: [
+    {start: 1, end: 2},
+    {start: 13, end: 14},
+    {start: 25, end: 76},
+  ],
+  right: [
+    {start: 1, end: 2},
+    {start: 23, end: 24},
+    {start: 55, end: 65},
+  ],
+};
+
+suite('gr-focus-layer', () => {
+  let layer: GrFocusLayer;
+  const line = new GrDiffLine(GrDiffLineType.ADD);
+
+  function createLineElement(lineNumber: number, side: Side) {
+    const lineNumberEl = document.createElement('div');
+    lineNumberEl.setAttribute('data-side', side);
+    lineNumberEl.setAttribute('data-value', lineNumber.toString());
+    lineNumberEl.className = side;
+    return lineNumberEl;
+  }
+
+  function createTextElement() {
+    const textElement = document.createElement('div');
+    textElement.innerText = 'A line of code';
+    return textElement;
+  }
+
+  suite('setRanges and notify', () => {
+    let listener: SinonStub;
+
+    setup(() => {
+      layer = new GrFocusLayer();
+      listener = sinon.stub();
+      layer.addListener(listener);
+    });
+
+    test('empty ranges do not notify', () => {
+      layer.annotated = true;
+      layer.setRanges();
+      assert.isFalse(listener.called);
+    });
+
+    test('do not notify while annotated is false', () => {
+      layer.setRanges(RANGES);
+      assert.isFalse(listener.called);
+    });
+
+    test('initial ranges', () => {
+      layer.annotated = true;
+      layer.setRanges(ONE_RANGE);
+      assert.isTrue(listener.called);
+      assert.equal(listener.callCount, 2);
+      assert.equal(listener.getCall(0).args[0], 1);
+      assert.equal(listener.getCall(0).args[1], 2);
+      assert.equal(listener.getCall(1).args[0], 9);
+      assert.equal(listener.getCall(1).args[1], 10);
+    });
+
+    test('old ranges and new range', () => {
+      layer.annotated = true;
+      layer.setRanges(ONE_RANGE);
+      listener.reset();
+      layer.annotate(
+        createTextElement(),
+        createLineElement(100, Side.RIGHT),
+        line,
+        Side.RIGHT
+      );
+      layer.annotate(
+        createTextElement(),
+        createLineElement(101, Side.RIGHT),
+        line,
+        Side.RIGHT
+      );
+      layer.setRanges(RANGES);
+      assert.isTrue(listener.called);
+      assert.equal(listener.callCount, 7);
+      assert.equal(listener.getCall(3).args[0], 100);
+      assert.equal(listener.getCall(3).args[1], 101);
+      assert.equal(listener.getCall(3).args[2], Side.RIGHT);
+    });
+  });
+
+  suite('annotate', () => {
+    function hasOutOfFocusClass(lineNumber: number, side: Side) {
+      const textEl = createTextElement();
+      layer.annotate(textEl, createLineElement(lineNumber, side), line, side);
+      return textEl.classList.contains('is-out-of-focus-range');
+    }
+
+    setup(() => {
+      layer = new GrFocusLayer();
+      layer.setRanges(RANGES);
+    });
+
+    test('annotated is true after annotate', () => {
+      assert.isFalse(hasOutOfFocusClass(1, Side.LEFT));
+      assert.isTrue(layer.annotated);
+    });
+
+    test('line 1-2 are focussed on both sides', () => {
+      assert.isFalse(hasOutOfFocusClass(1, Side.LEFT));
+      assert.isFalse(hasOutOfFocusClass(2, Side.RIGHT));
+      assert.isFalse(hasOutOfFocusClass(1, Side.LEFT));
+      assert.isFalse(hasOutOfFocusClass(2, Side.RIGHT));
+    });
+
+    test('line 3-12 are not focussed on left side', () => {
+      for (let index = 3; index < 12; index++) {
+        assert.isTrue(hasOutOfFocusClass(index, Side.LEFT));
+      }
+    });
+
+    test('line 3-22 are not focussed on right side', () => {
+      for (let index = 3; index < 22; index++) {
+        assert.isTrue(hasOutOfFocusClass(index, Side.RIGHT));
+      }
+    });
+
+    test('line 13-14 are focussed on left side', () => {
+      assert.isFalse(hasOutOfFocusClass(13, Side.LEFT));
+      assert.isFalse(hasOutOfFocusClass(14, Side.LEFT));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 338ac07..a7e215b 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import '../gr-diff/gr-diff-line';
 import './gr-ranged-comment-layer';
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
index 38a9533..c67157f 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -8,10 +8,10 @@
 const $_documentContainer = document.createElement('template');
 
 export const grRangedCommentTheme = css`
-  .rangeHighlight {
+  gr-diff-text hl.rangeHighlight {
     background-color: var(--diff-highlight-range-color);
   }
-  .rangeHoverHighlight {
+  gr-diff-text hl.rangeHoverHighlight {
     background-color: var(--diff-highlight-range-hover-color);
   }
 `;
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
index 67836a4..5cc8409 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import './gr-selection-action-box';
 import {GrSelectionActionBox} from './gr-selection-action-box';
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index 5074290..9337951 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -23,6 +23,7 @@
   ['application/typescript', 'typescript'],
   ['application/xml', 'xml'],
   ['application/xquery', 'xquery'],
+  ['application/x-epp', 'epp'],
   ['application/x-erb', 'erb'],
   ['text/css', 'css'],
   ['text/html', 'html'],
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index 221eada..1d8b4ed 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
 import {getAppContext} from '../../../services/app-context';
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6de43ed..cbb2d8c 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -13,6 +13,7 @@
 import '../api/embed';
 import '../scripts/bundled-polymer';
 import './diff/gr-diff/gr-diff';
+import './gr-textarea';
 import './diff/gr-diff-cursor/gr-diff-cursor';
 import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
 import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
new file mode 100644
index 0000000..42f2452
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -0,0 +1,798 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+  GrTextarea as GrTextareaApi,
+  HintAppliedEventDetail,
+  HintShownEventDetail,
+  HintDismissedEventDetail,
+  CursorPositionChangeEventDetail,
+} from '../api/embed';
+
+/**
+ * Waits for the next animation frame.
+ */
+async function animationFrame(): Promise<void> {
+  return new Promise(resolve => {
+    requestAnimationFrame(() => {
+      resolve();
+    });
+  });
+}
+
+/**
+ * Whether the current browser supports `plaintext-only` for contenteditable
+ * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
+ */
+function supportsPlainTextEditing() {
+  const div = document.createElement('div');
+  try {
+    div.contentEditable = 'PLAINTEXT-ONLY';
+    return div.contentEditable === 'plaintext-only';
+  } catch (e) {
+    return false;
+  }
+}
+
+/** Class for autocomplete hint */
+export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
+
+const ACCEPT_PLACEHOLDER_HINT_LABEL =
+  'Press TAB to accept the placeholder hint.';
+
+/**
+ * A custom textarea component which allows autocomplete functionality.
+ * This component is only supported in Chrome. Other browsers are not supported.
+ *
+ * Example usage:
+ * <gr-textarea></gr-textarea>
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends LitElement implements GrTextareaApi {
+  // editableDivElement is available right away where it may be undefined. This
+  // is used for calls for scrollTop as if it is undefined then we can fallback
+  // to 0. For other usecases use editableDiv.
+  @query('.editableDiv')
+  private readonly editableDivElement?: HTMLDivElement;
+
+  @queryAsync('.editableDiv')
+  private readonly editableDiv?: Promise<HTMLDivElement>;
+
+  @property({type: Boolean, reflect: true}) disabled = false;
+
+  @property({type: String, reflect: true}) placeholder: string | undefined;
+
+  /**
+   * The hint is shown as a autocomplete string which can be added by pressing
+   * TAB.
+   *
+   * The hint is shown
+   *  1. At the cursor position, only when cursor position is at the end of
+   *     textarea content.
+   *  2. When textarea has focus.
+   *  3. When selection inside the textarea is collapsed.
+   *
+   * When hint is applied listen for hintApplied event and remove the hint
+   * as component property to avoid showing the hint again.
+   */
+  @property({type: String})
+  set hint(newHint) {
+    if (this.hint !== newHint) {
+      this.innerHint = newHint;
+      this.updateHintInDomIfRendered();
+    }
+  }
+
+  get hint() {
+    return this.innerHint;
+  }
+
+  /**
+   * Show hint is shown as placeholder which people can autocomplete to.
+   *
+   * This takes precedence over hint property.
+   * It is shown even when textarea has no focus.
+   * This is shown only when textarea is blank.
+   */
+  @property({type: String}) placeholderHint: string | undefined;
+
+  /**
+   * Sets the value for textarea and also renders it in dom if it is different
+   * from last rendered value.
+   *
+   * To prevent cursor position from jumping to front of text even when value
+   * remains same, Check existing value before triggering the update and only
+   * update when there is a change.
+   *
+   * Also .innerText binding can't be used for security reasons.
+   */
+  @property({type: String})
+  set value(newValue) {
+    if (this.ignoreValue && this.ignoreValue === newValue) {
+      return;
+    }
+    const oldVal = this.value;
+    if (oldVal !== newValue) {
+      this.innerValue = newValue;
+      this.updateValueInDom();
+    }
+  }
+
+  get value() {
+    return this.innerValue;
+  }
+
+  /**
+   * This value will be ignored by textarea and is not set.
+   */
+  @property({type: String}) ignoreValue: string | undefined;
+
+  /**
+   * Sets cursor at the end of content on focus.
+   */
+  @property({type: Boolean}) putCursorAtEndOnFocus = false;
+
+  /**
+   * Enables save shortcut.
+   *
+   * On S key down with control or meta key enabled is exposed with output event
+   * 'saveShortcut'.
+   */
+  @property({type: Boolean}) enableSaveShortcut = false;
+
+  /*
+   * Is textarea focused. This is a readonly property.
+   */
+  get isFocused(): boolean {
+    return this.focused;
+  }
+
+  /**
+   * Native element for editable div.
+   */
+  get nativeElement() {
+    return this.editableDivElement;
+  }
+
+  /**
+   * Scroll Top for editable div.
+   */
+  override get scrollTop() {
+    return this.editableDivElement?.scrollTop ?? 0;
+  }
+
+  private innerValue: string | undefined;
+
+  private innerHint: string | undefined;
+
+  private focused = false;
+
+  private currentCursorPosition = -1;
+
+  private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          position: relative;
+          width: 100%;
+        }
+
+        :host([disabled]) {
+          .editableDiv {
+            background-color: var(--input-field-disabled-bg, lightgrey);
+            color: var(--text-disabled, black);
+            cursor: default;
+          }
+        }
+
+        .editableDiv {
+          background-color: var(--input-field-bg, white);
+          border: var(--gr-textarea-border-width, 2px) solid
+            var(--gr-textarea-border-color, white);
+          border-radius: 4px;
+          box-sizing: border-box;
+          color: var(--text-default, black);
+          max-height: var(--gr-textarea-max-height, 16em);
+          min-height: var(--gr-textarea-min-height, 4em);
+          overflow-x: auto;
+          padding: var(--gr-textarea-padding, 12px);
+          white-space: pre-wrap;
+          width: 100%;
+
+          &:focus-visible {
+            border-color: var(--gr-textarea-focus-outline-color, black);
+            outline: none;
+          }
+
+          &:empty::before {
+            content: attr(data-placeholder);
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+          }
+
+          &.hintShown:empty::after,
+          .autocomplete-hint:empty::after {
+            background-color: var(--secondary-bg-color, white);
+            border: 1px solid var(--text-secondary, lightgrey);
+            border-radius: 2px;
+            content: 'tab';
+            color: var(--text-secondary, lightgrey);
+            display: inline;
+            pointer-events: none;
+            font-size: 10px;
+            line-height: 10px;
+            margin-left: 4px;
+            padding: 1px 3px;
+          }
+
+          .autocomplete-hint {
+            &:empty::before {
+              content: attr(data-hint);
+              color: var(--text-secondary, lightgrey);
+            }
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const isHintShownAsPlaceholder =
+      (!this.disabled && this.placeholderHint) ?? false;
+
+    const placeholder = isHintShownAsPlaceholder
+      ? this.placeholderHint
+      : this.placeholder;
+    const ariaPlaceholder = isHintShownAsPlaceholder
+      ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
+      : placeholder;
+
+    const classes = classMap({
+      editableDiv: true,
+      hintShown: isHintShownAsPlaceholder,
+    });
+
+    // Chrome supports non-standard "contenteditable=plaintext-only",
+    // which prevents HTML from being inserted into a contenteditable element.
+    // https://github.com/w3c/editing/issues/162
+    return html`<div
+      aria-disabled=${this.disabled}
+      aria-multiline="true"
+      aria-placeholder=${ifDefined(ariaPlaceholder)}
+      data-placeholder=${ifDefined(placeholder)}
+      class=${classes}
+      contenteditable=${this.contentEditableAttributeValue}
+      dir="ltr"
+      role="textbox"
+      @input=${this.onInput}
+      @focus=${this.onFocus}
+      @blur=${this.onBlur}
+      @keydown=${this.handleKeyDown}
+      @keyup=${this.handleKeyUp}
+      @mouseup=${this.handleMouseUp}
+      @scroll=${this.handleScroll}
+    ></div>`;
+  }
+
+  /**
+   * Focuses the textarea.
+   */
+  override async focus() {
+    const editableDivElement = await this.editableDiv;
+    const isFocused = this.isFocused;
+    editableDivElement?.focus?.();
+    // If already focused, do not change the cursor position.
+    if (this.putCursorAtEndOnFocus && !isFocused) {
+      await this.putCursorAtEnd();
+    }
+  }
+
+  /**
+   * Puts the cursor at the end of existing content.
+   * Scrolls the content of textarea towards the end.
+   */
+  async putCursorAtEnd() {
+    const editableDivElement = await this.editableDiv;
+    const selection = this.getSelection();
+
+    if (!editableDivElement || !selection) {
+      return;
+    }
+
+    const range = document.createRange();
+    editableDivElement.focus();
+    range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    this.onCursorPositionChange();
+  }
+
+  public setCursorPosition(position: number) {
+    this.setCursorPositionForDiv(position, this.editableDivElement);
+  }
+
+  public async setCursorPositionAsync(position: number) {
+    const editableDivElement = await this.editableDiv;
+    this.setCursorPositionForDiv(position, editableDivElement);
+  }
+
+  /**
+   * Sets cursor position to given position and scrolls the content to cursor
+   * position.
+   *
+   * If position is out of bounds of value of textarea then cursor is places at
+   * end of content of textarea.
+   */
+  private setCursorPositionForDiv(
+    position: number,
+    editableDivElement?: HTMLDivElement
+  ) {
+    // This will keep track of remaining offset to place the cursor.
+    let remainingOffset = position;
+    let isOnFreshLine = true;
+    let nodeToFocusOn: Node | null = null;
+    const selection = this.getSelection();
+
+    if (!editableDivElement || !selection) {
+      return;
+    }
+    editableDivElement.focus();
+    const findNodeToFocusOn = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+        let currentNodeLength = 0;
+
+        if (childNode.nodeType === Node.COMMENT_NODE) {
+          continue;
+        }
+
+        if (childNode.nodeName === 'BR') {
+          currentNodeLength++;
+          isOnFreshLine = true;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          currentNodeLength++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
+          currentNodeLength += childNode.textContent.length;
+        }
+
+        if (remainingOffset <= currentNodeLength) {
+          nodeToFocusOn = childNode;
+          break;
+        } else {
+          remainingOffset -= currentNodeLength;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findNodeToFocusOn(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    findNodeToFocusOn(Array.from(editableDivElement.childNodes));
+
+    this.setFocusOnNode(
+      selection,
+      editableDivElement,
+      nodeToFocusOn,
+      remainingOffset
+    );
+  }
+
+  /**
+   * Replaces text from start and end cursor position.
+   */
+  setRangeText(replacement: string, start: number, end: number) {
+    const pre = this.value?.substring(0, start) ?? '';
+    const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
+
+    this.value = pre + replacement + post;
+    this.setCursorPosition(pre.length + replacement.length);
+  }
+
+  private get contentEditableAttributeValue() {
+    return this.disabled
+      ? 'false'
+      : this.isPlaintextOnlySupported
+      ? ('plaintext-only' as unknown as 'true')
+      : 'true';
+  }
+
+  private setFocusOnNode(
+    selection: Selection,
+    editableDivElement: Node,
+    nodeToFocusOn: Node | null,
+    remainingOffset: number
+  ) {
+    const range = document.createRange();
+    // If node is null or undefined then fallback to focus event which will put
+    // cursor at the end of content.
+    if (nodeToFocusOn === null) {
+      range.setStart(editableDivElement, editableDivElement.childNodes.length);
+    }
+    // If node to focus is BR then focus offset is number of nodes.
+    else if (nodeToFocusOn.nodeName === 'BR') {
+      const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
+      range.setEnd(nextNode, 0);
+    } else {
+      range.setStart(nodeToFocusOn, remainingOffset);
+    }
+
+    range.collapse(true);
+    selection.removeAllRanges();
+    selection.addRange(range);
+
+    // Scroll the content to cursor position.
+    this.scrollToCursorPosition(range);
+
+    range.detach();
+
+    this.onCursorPositionChange();
+  }
+
+  private async onInput(event: Event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+
+    const value = await this.getValue();
+    this.innerValue = value;
+
+    this.fire('input', {value: this.value});
+  }
+
+  private onFocus() {
+    this.focused = true;
+    this.onCursorPositionChange();
+  }
+
+  private onBlur() {
+    this.focused = false;
+    this.removeHintSpanIfShown();
+    this.onCursorPositionChange();
+  }
+
+  private async handleKeyDown(event: KeyboardEvent) {
+    if (
+      event.key === 'Tab' &&
+      !event.shiftKey &&
+      !event.ctrlKey &&
+      !event.metaKey
+    ) {
+      await this.handleTabKeyPress(event);
+      return;
+    }
+    if (
+      this.enableSaveShortcut &&
+      event.key === 's' &&
+      (event.ctrlKey || event.metaKey)
+    ) {
+      event.preventDefault();
+      this.fire('saveShortcut');
+    }
+    // Prevent looping of cursor position when CTRL+ARROW_LEFT/ARROW_RIGHT is
+    // pressed.
+    if (event.ctrlKey || event.metaKey || event.altKey) {
+      if (event.key === 'ArrowLeft' && this.currentCursorPosition === 0) {
+        event.preventDefault();
+      }
+      if (
+        event.key === 'ArrowRight' &&
+        this.currentCursorPosition === (this.value?.length ?? 0)
+      ) {
+        event.preventDefault();
+      }
+    }
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private handleKeyUp() {
+    this.onCursorPositionChange();
+  }
+
+  private async handleMouseUp() {
+    this.onCursorPositionChange();
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private handleScroll() {
+    this.fire('scroll');
+  }
+
+  private fire<T>(type: string, detail?: T) {
+    this.dispatchEvent(
+      new CustomEvent(type, {detail, bubbles: true, composed: true})
+    );
+  }
+
+  private async handleTabKeyPress(event: KeyboardEvent) {
+    const oldValue = this.value;
+    if (this.placeholderHint && !oldValue) {
+      event.preventDefault();
+      await this.appendHint(this.placeholderHint, event);
+    } else if (this.hasHintSpan()) {
+      event.preventDefault();
+      await this.appendHint(this.hint!, event);
+    } else {
+      // Add tab \t to cursor position if inside a code snippet ```
+      const cursorPosition = await this.getCursorPositionAsync();
+      const textValue = await this.getValue();
+
+      const startCodeSnippet = textValue.lastIndexOf('```', cursorPosition - 1);
+      const endCodeSnippet = textValue.indexOf('```', cursorPosition);
+
+      if (
+        startCodeSnippet !== -1 &&
+        endCodeSnippet !== -1 &&
+        endCodeSnippet > startCodeSnippet
+      ) {
+        event.preventDefault();
+        this.setRangeText('\t', cursorPosition, cursorPosition);
+      }
+    }
+  }
+
+  private async appendHint(hint: string, event: Event) {
+    const oldValue = this.value ?? '';
+    const newValue = oldValue + hint;
+
+    this.value = newValue;
+    await this.putCursorAtEnd();
+    await this.onInput(event);
+
+    this.fire('hintApplied', {hint, oldValue});
+  }
+
+  private async toggleHintVisibilityIfAny() {
+    // Wait for the next animation frame so that entered key is processed and
+    // available in dom.
+    await animationFrame();
+
+    const editableDivElement = await this.editableDiv;
+    const currentValue = (await this.getValue()) ?? '';
+    const cursorPosition = await this.getCursorPositionAsync();
+    if (
+      !editableDivElement ||
+      (this.placeholderHint && !currentValue) ||
+      !this.hint ||
+      !this.isFocused ||
+      cursorPosition !== currentValue.length
+    ) {
+      this.removeHintSpanIfShown();
+      return;
+    }
+
+    const hintSpan = this.hintSpan();
+    if (!hintSpan) {
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+      return;
+    }
+
+    const oldHint = (hintSpan as HTMLElement).dataset['hint'];
+    if (oldHint !== this.hint) {
+      this.removeHintSpanIfShown();
+      this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+    }
+  }
+
+  private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+    const oldValue = this.value ?? '';
+    const hintSpan = document.createElement('span');
+    hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
+    hintSpan.setAttribute('role', 'alert');
+    hintSpan.setAttribute(
+      'aria-label',
+      'Suggestion: ' + hint + ' Press TAB to accept it.'
+    );
+    hintSpan.dataset['hint'] = hint;
+    editableDivElement.appendChild(hintSpan);
+    this.fire('hintShown', {hint, oldValue});
+  }
+
+  private removeHintSpanIfShown() {
+    const hintSpan = this.hintSpan();
+    if (hintSpan) {
+      hintSpan.remove();
+      this.fire('hintDismissed', {
+        hint: (hintSpan as HTMLElement).dataset['hint'],
+      });
+    }
+  }
+
+  private hasHintSpan() {
+    return !!this.hintSpan();
+  }
+
+  private hintSpan() {
+    return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
+  }
+
+  private onCursorPositionChange() {
+    const cursorPosition = this.getCursorPosition();
+    this.fire('cursorPositionChange', {position: cursorPosition});
+    this.currentCursorPosition = cursorPosition;
+  }
+
+  private async updateValueInDom() {
+    const editableDivElement =
+      this.editableDivElement ?? (await this.editableDiv);
+    if (editableDivElement) {
+      editableDivElement.innerText = this.value || '';
+    }
+  }
+
+  private async updateHintInDomIfRendered() {
+    // Wait for editable div to render then process the hint.
+    await this.editableDiv;
+    await this.toggleHintVisibilityIfAny();
+  }
+
+  private async getValue() {
+    const editableDivElement = await this.editableDiv;
+    if (editableDivElement) {
+      const [output] = this.parseText(editableDivElement, false, true);
+      return output;
+    }
+    return '';
+  }
+
+  private parseText(
+    node: Node,
+    isLastBr: boolean,
+    isFirst: boolean
+  ): [string, boolean] {
+    let textValue = '';
+    let output = '';
+    if (node.nodeName === 'BR') {
+      return ['\n', true];
+    }
+
+    if (node.nodeType === Node.TEXT_NODE && node.textContent) {
+      return [node.textContent, false];
+    }
+
+    if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
+      textValue = '\n';
+    }
+
+    isLastBr = false;
+
+    for (let i = 0; i < node.childNodes?.length; i++) {
+      [output, isLastBr] = this.parseText(
+        node.childNodes[i],
+        isLastBr,
+        i === 0
+      );
+      textValue += output;
+    }
+    return [textValue, isLastBr];
+  }
+
+  public getCursorPosition() {
+    return this.getCursorPositionForDiv(this.editableDivElement);
+  }
+
+  public async getCursorPositionAsync() {
+    const editableDivElement = await this.editableDiv;
+    return this.getCursorPositionForDiv(editableDivElement);
+  }
+
+  private getCursorPositionForDiv(editableDivElement?: HTMLDivElement) {
+    const selection = this.getSelection();
+
+    // Cursor position is -1 (not available) if
+    //
+    // If textarea is not rendered.
+    // If textarea is not focused
+    // There is no accessible selection object.
+    // This is not a collapsed selection.
+    if (
+      !editableDivElement ||
+      !this.focused ||
+      !selection ||
+      selection.focusNode === null ||
+      !selection.isCollapsed
+    ) {
+      return -1;
+    }
+
+    let cursorPosition = 0;
+    let isOnFreshLine = true;
+
+    const findCursorPosition = (childNodes: Node[]) => {
+      for (let i = 0; i < childNodes.length; i++) {
+        const childNode = childNodes[i];
+
+        if (childNode.nodeName === 'BR') {
+          cursorPosition++;
+          isOnFreshLine = true;
+          continue;
+        }
+
+        if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+          cursorPosition++;
+        }
+
+        isOnFreshLine = false;
+
+        if (childNode === selection.focusNode) {
+          cursorPosition += selection.focusOffset;
+          break;
+        } else if (childNode.nodeType === 3 && childNode.textContent) {
+          cursorPosition += childNode.textContent.length;
+        }
+
+        if (childNode.childNodes?.length > 0) {
+          findCursorPosition(Array.from(childNode.childNodes));
+        }
+      }
+    };
+
+    if (editableDivElement === selection.focusNode) {
+      // If focus node is the top textarea then focusOffset is the number of
+      // child nodes before the cursor position.
+      const partOfNodes = Array.from(editableDivElement.childNodes).slice(
+        0,
+        selection.focusOffset
+      );
+      findCursorPosition(partOfNodes);
+    } else {
+      findCursorPosition(Array.from(editableDivElement.childNodes));
+    }
+
+    return cursorPosition;
+  }
+
+  /** Gets the current selection, preferring the shadow DOM selection. */
+  private getSelection(): Selection | undefined | null {
+    // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
+    return this.shadowRoot?.getSelection?.();
+  }
+
+  private scrollToCursorPosition(range: Range) {
+    const tempAnchorEl = document.createElement('br');
+    range.insertNode(tempAnchorEl);
+
+    tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+
+    tempAnchorEl.remove();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-textarea': GrTextarea;
+  }
+  interface HTMLElementEventMap {
+    // prettier-ignore
+    'saveShortcut': CustomEvent<{}>;
+    // prettier-ignore
+    'hintApplied': CustomEvent<HintAppliedEventDetail>;
+    // prettier-ignore
+    'hintShown': CustomEvent<HintShownEventDetail>;
+    // prettier-ignore
+    'hintDismissed': CustomEvent<HintDismissedEventDetail>;
+    // prettier-ignore
+    'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
new file mode 100644
index 0000000..577ee71
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -0,0 +1,307 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitForEventOnce} from '../utils/event-util';
+import {AUTOCOMPLETE_HINT_CLASS, GrTextarea} from './gr-textarea';
+import {CursorPositionChangeEventDetail} from '../api/embed';
+
+async function rafPromise() {
+  return new Promise(res => {
+    requestAnimationFrame(res);
+  });
+}
+
+suite('gr-textarea test', () => {
+  let element: GrTextarea;
+
+  setup(async () => {
+    element = await fixture(html` <gr-textarea> </gr-textarea>`);
+  });
+
+  test('text area is registered correctly', () => {
+    assert.instanceOf(element, GrTextarea);
+  });
+
+  test('when disabled textarea have contenteditable set to false', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('contenteditable'), 'false');
+  });
+
+  test('when disabled textarea have aria-disabled set', async () => {
+    element.disabled = true;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.isDefined(editableDiv?.getAttribute('aria-disabled'));
+  });
+
+  test('when textarea has placeholder, set aria-placeholder to placeholder text', async () => {
+    const placeholder = 'A sample placehodler...';
+    element.placeholder = placeholder;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.getAttribute('aria-placeholder'), placeholder);
+  });
+
+  test('renders the value', async () => {
+    const value = 'Some value';
+    element.value = value;
+    await element.updateComplete;
+
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.innerText, value);
+  });
+
+  test('streams change event when editable div has input event', async () => {
+    const value = 'Some value \n other value';
+    const INPUT_EVENT = 'input';
+    let changeCalled = false;
+
+    element.addEventListener(INPUT_EVENT, () => {
+      changeCalled = true;
+    });
+
+    const changeEventPromise = waitForEventOnce(element, INPUT_EVENT);
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+
+    editableDiv.innerText = value;
+    editableDiv.dispatchEvent(new Event('input'));
+    await changeEventPromise;
+
+    assert.isTrue(changeCalled);
+  });
+
+  test('does not have focus by default', async () => {
+    assert.isFalse(element.isFocused);
+  });
+
+  test('when focused, isFocused is set to true', async () => {
+    await element.focus();
+    assert.isTrue(element.isFocused);
+  });
+
+  test('when cursor position is set to 0', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.setCursorPosition(0);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 0);
+  });
+
+  test('when cursor position is set to 1', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some value';
+    await element.updateComplete;
+    element.setCursorPosition(1);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 1);
+  });
+
+  test('when cursor position is set to new line', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+
+    const cursorPositionChangeEventPromise = waitForEventOnce(
+      element,
+      CURSOR_POSITION_CHANGE_EVENT
+    );
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+
+    element.value = 'Some \n\n\n value';
+    await element.updateComplete;
+    element.setCursorPosition(7);
+    await cursorPositionChangeEventPromise;
+
+    assert.equal(cursorPosition, 7);
+  });
+
+  test('when textarea is empty, placeholder hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+
+    assert.equal(editableDiv?.dataset['placeholder'], placeholderHint);
+  });
+
+  test('when TAB is pressed, placeholder hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const placeholderHint = 'Some value';
+
+    element.placeholderHint = placeholderHint;
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, placeholderHint);
+  });
+
+  test('when cursor is at end, hint is shown', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    await element.updateComplete;
+    element.value = oldValue;
+    await element.putCursorAtEnd();
+    await element.updateComplete;
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await element.updateComplete;
+    await rafPromise();
+
+    const spanHintElement = editableDiv?.querySelector(
+      '.' + AUTOCOMPLETE_HINT_CLASS
+    ) as HTMLSpanElement;
+    const styles = window.getComputedStyle(spanHintElement, ':before');
+    assert.equal(styles['content'], '"' + hint + '"');
+  });
+
+  test('when TAB is pressed, hint is added as content', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const oldValue = 'Hola';
+    const hint = 'amigos';
+
+    element.hint = hint;
+    element.value = oldValue;
+    await element.updateComplete;
+    await element.putCursorAtEnd();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+    await rafPromise();
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+
+    assert.equal(element.value, oldValue + hint);
+  });
+
+  test('when cursor is at end, Mod + ArrowRight does not change cursor position', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+    const value = 'Hola amigos';
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+    await element.updateComplete;
+    element.value = value;
+    await element.putCursorAtEnd();
+    await element.updateComplete;
+
+    editableDiv.dispatchEvent(
+      new KeyboardEvent('keydown', {key: 'ArrowRight', metaKey: true})
+    );
+    await element.updateComplete;
+    await rafPromise();
+
+    assert.equal(cursorPosition, value.length);
+  });
+
+  test('when cursor is at 0, Mod + ArrowLeft does not change cursor position', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+    const value = 'Hola amigos';
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+    await element.updateComplete;
+    element.value = value;
+    element.setCursorPosition(0);
+    await element.updateComplete;
+
+    editableDiv.dispatchEvent(
+      new KeyboardEvent('keydown', {key: 'ArrowLeft', metaKey: true})
+    );
+    await element.updateComplete;
+    await rafPromise();
+
+    assert.equal(cursorPosition, 0);
+  });
+
+  test('when TAB is pressed inside a code snippet, a tab is added', async () => {
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    const textContent = 'Some text\n```\ncode snippet\n```\nMore text';
+
+    element.value = textContent;
+    await element.updateComplete;
+
+    // Set cursor position inside the code snippet
+    element.setCursorPosition(19); // Position after 'code '
+
+    editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+    await element.updateComplete;
+    await rafPromise();
+
+    const expectedValue = 'Some text\n```\ncode \tsnippet\n```\nMore text';
+    assert.equal(element.value, expectedValue);
+  });
+});
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
deleted file mode 100644
index 53c90a6..0000000
--- a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import '../../test/common-test-setup';
-import {EmailAddress} from '../../api/rest-api';
-import {getAppContext} from '../../services/app-context';
-import {stubRestApi} from '../../test/test-utils';
-import {AccountsModel} from './accounts-model';
-import {assert} from '@open-wc/testing';
-
-suite('accounts-model tests', () => {
-  let model: AccountsModel;
-
-  setup(() => {
-    model = new AccountsModel(getAppContext().restApiService);
-  });
-
-  teardown(() => {
-    model.finalize();
-  });
-
-  test('invalid account makes only one request', () => {
-    const response = {...new Response(), status: 404};
-    const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
-      (_, errFn) => {
-        if (errFn !== undefined) {
-          errFn(response);
-        }
-        return Promise.resolve(undefined);
-      }
-    );
-
-    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
-    assert.equal(getAccountDetails.callCount, 1);
-
-    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
-    assert.equal(getAccountDetails.callCount, 1);
-  });
-});
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts/accounts-model.ts
similarity index 95%
rename from polygerrit-ui/app/models/accounts-model/accounts-model.ts
rename to polygerrit-ui/app/models/accounts/accounts-model.ts
index 0802f06..6eedcbe8 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts/accounts-model.ts
@@ -42,7 +42,7 @@
   ): Promise<AccountDetailInfo | AccountInfo> {
     const current = this.getState();
     const id = getUserId(partialAccount);
-    if (hasOwnProperty(current.accounts, id)) return current.accounts[id];
+    if (hasOwnProperty(current.accounts, id)) return {...current.accounts[id]};
     // It is possible to add emails to CC when they don't have a Gerrit
     // account. In this case getAccountDetails will return a 404 error then
     // we at least use what is in partialAccount.
diff --git a/polygerrit-ui/app/models/accounts/accounts-model_test.ts b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
new file mode 100644
index 0000000..e84723c
--- /dev/null
+++ b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import {
+  AccountDetailInfo,
+  AccountId,
+  EmailAddress,
+  Timestamp,
+} from '../../api/rest-api';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {AccountsModel} from './accounts-model';
+import {assert} from '@open-wc/testing';
+
+const KERMIT: AccountDetailInfo = {
+  _account_id: 1 as AccountId,
+  name: 'Kermit',
+  registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+};
+
+suite('accounts-model tests', () => {
+  let model: AccountsModel;
+
+  setup(() => {
+    model = new AccountsModel(getAppContext().restApiService);
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('basic lookup', async () => {
+    const stub = stubRestApi('getAccountDetails').returns(
+      Promise.resolve(KERMIT)
+    );
+
+    let filled = await model.fillDetails({_account_id: 1 as AccountId});
+    assert.equal(filled.name, 'Kermit');
+    assert.equal(filled, KERMIT);
+    assert.equal(stub.callCount, 1);
+
+    filled = await model.fillDetails({_account_id: 1 as AccountId});
+    assert.equal(filled.name, 'Kermit');
+    // Cache objects are cloned on lookup, so this is a different object.
+    assert.notEqual(filled, KERMIT);
+    // Did not have to call the REST API again.
+    assert.equal(stub.callCount, 1);
+  });
+
+  test('invalid account makes only one request', () => {
+    const response = {...new Response(), status: 404};
+    const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
+      (_, errFn) => {
+        if (errFn !== undefined) {
+          errFn(response);
+        }
+        return Promise.resolve(undefined);
+      }
+    );
+
+    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+    assert.equal(getAccountDetails.callCount, 1);
+
+    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+    assert.equal(getAccountDetails.callCount, 1);
+  });
+});
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index 1ac9593..8f2c751 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {
   createAccountWithIdNameAndEmail,
   createChange,
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 2414107..04fa0d6 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -19,7 +19,13 @@
 } from '../../types/common';
 import {ChangeStatus, DefaultBase} from '../../constants/constants';
 import {combineLatest, from, Observable, forkJoin, of} from 'rxjs';
-import {map, filter, withLatestFrom, switchMap} from 'rxjs/operators';
+import {
+  map,
+  filter,
+  withLatestFrom,
+  switchMap,
+  catchError,
+} from 'rxjs/operators';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
@@ -40,8 +46,6 @@
   ChangeChildView,
   ChangeViewModel,
   createChangeUrl,
-  createDiffUrl,
-  createEditUrl,
 } from '../views/change';
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
 import {getRevertCreatedChangeIds} from '../../utils/message-util';
@@ -55,7 +59,7 @@
 export interface ChangeState {
   /**
    * If `change` is undefined, this must be either NOT_LOADED or LOADING.
-   * If `change` is defined, this must be either LOADED or RELOADING.
+   * If `change` is defined, this must be either LOADED.
    */
   loadingStatus: LoadingStatus;
   change?: ParsedChangeInfo;
@@ -177,7 +181,13 @@
 };
 
 export const changeModelToken = define<ChangeModel>('change-model');
-
+/**
+ * Change model maintains information about the current change.
+ *
+ * The "current" change is defined by ChangeViewModel. This model tracks part of
+ * the current view. As such it's a singleton global state. It's NOT meant to
+ * keep the state of an arbitrary change.
+ */
 export class ChangeModel extends Model<ChangeState> {
   private change?: ParsedChangeInfo;
 
@@ -199,8 +209,7 @@
 
   public readonly loading$ = select(
     this.changeLoadingStatus$,
-    status =>
-      status === LoadingStatus.LOADING || status === LoadingStatus.RELOADING
+    status => status === LoadingStatus.LOADING
   );
 
   public readonly reviewedFiles$ = select(
@@ -388,10 +397,7 @@
 
   private reportChangeReload() {
     return this.changeLoadingStatus$.subscribe(loadingStatus => {
-      if (
-        loadingStatus === LoadingStatus.LOADING ||
-        loadingStatus === LoadingStatus.RELOADING
-      ) {
+      if (loadingStatus === LoadingStatus.LOADING) {
         this.reporting.time(Timing.CHANGE_RELOAD);
       }
       if (
@@ -557,16 +563,21 @@
     return this.viewModel.changeNum$
       .pipe(
         switchMap(changeNum => {
-          if (changeNum !== undefined) this.updateStateLoading(changeNum);
-          const change = from(this.restApiService.getChangeDetail(changeNum));
-          const edit = from(this.restApiService.getChangeEdit(changeNum));
+          this.updateStateLoading(changeNum);
+          // if changeNum is undefined restApi calls return undefined.
+          const change = this.restApiService.getChangeDetail(changeNum);
+          const edit = this.restApiService.getChangeEdit(changeNum);
           return forkJoin([change, edit]);
         }),
         withLatestFrom(this.viewModel.patchNum$),
         map(([[change, edit], patchNum]) =>
           updateChangeWithEdit(change, edit, patchNum)
         ),
-        map(updateRevisionsWithCommitShas)
+        catchError(err => {
+          // Reset loading state and re-throw.
+          this.updateState({loadingStatus: LoadingStatus.NOT_LOADED});
+          throw err;
+        })
       )
       .subscribe(change => {
         // The change service is currently a singleton, so we have to be
@@ -644,27 +655,13 @@
     return this.getState().change;
   }
 
-  diffUrl(
-    diffView: {path: string; lineNum?: number},
-    patchNum = this.patchNum,
-    basePatchNum = this.basePatchNum
-  ) {
-    if (!this.change) return;
-    if (!this.patchNum) return;
-    return createDiffUrl({
-      change: this.change,
-      patchNum,
-      basePatchNum,
-      diffView,
-    });
-  }
-
   navigateToDiff(
     diffView: {path: string; lineNum?: number},
     patchNum = this.patchNum,
     basePatchNum = this.basePatchNum
   ) {
-    const url = this.diffUrl(diffView, patchNum, basePatchNum);
+    if (!patchNum) return;
+    const url = this.viewModel.diffUrl({diffView, patchNum, basePatchNum});
     if (!url) return;
     this.navigation.setUrl(url);
   }
@@ -702,18 +699,9 @@
     this.navigation.setUrl(url);
   }
 
-  editUrl(editView: {path: string; lineNum?: number}) {
-    if (!this.change) return;
-    return createEditUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchNum,
-      editView,
-    });
-  }
-
   navigateToEdit(editView: {path: string; lineNum?: number}) {
-    const url = this.editUrl(editView);
+    if (!this.patchNum) return;
+    const url = this.viewModel.editUrl({editView, patchNum: this.patchNum});
     if (!url) return;
     this.navigation.setUrl(url);
   }
@@ -726,51 +714,53 @@
    *     has been loaded, and false if a newer patch has been uploaded in the
    *     meantime. The promise is rejected on network error.
    */
-  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
-    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-    return this.restApiService.getChangeDetail(change._number).then(detail => {
-      if (!detail) {
-        const error = new Error('Change detail not found.');
-        return Promise.reject(error);
-      }
-      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-      if (!actualLatest || !knownLatest) {
-        const error = new Error('Unable to check for latest patchset.');
-        return Promise.reject(error);
-      }
-      return {
-        isLatest: actualLatest <= knownLatest,
-        newStatus: change.status !== detail.status ? detail.status : null,
-        newMessages:
-          (change.messages || []).length < (detail.messages || []).length
-            ? detail.messages![detail.messages!.length - 1]
-            : undefined,
-      };
-    });
+  async fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = change.current_revision_number;
+    const detail = await this.restApiService.getChange(change._number);
+    if (!detail) {
+      throw new Error('Change request failed.');
+    }
+    const actualLatest = detail.current_revision_number;
+    return {
+      isLatest: actualLatest <= knownLatest,
+      newStatus: change.status !== detail.status ? detail.status : null,
+      newMessages:
+        (change.messages || []).length < (detail.messages || []).length
+          ? detail.messages![detail.messages!.length - 1]
+          : undefined,
+    };
   }
 
   /**
    * Called when change detail loading is initiated.
    *
-   * If the change number matches the current change in the state, then
-   * this is a reload. If not, then we not just want to set the state to
-   * LOADING instead of RELOADING, but we also want to set the change to
+   * We want to set the state to LOADING, but we also want to set the change to
    * undefined right away. Otherwise components could see inconsistent state:
    * a new change number, but an old change.
    */
-  private updateStateLoading(changeNum: NumericChangeId) {
-    const current = this.getState();
-    const reloading = current.change?._number === changeNum;
+  private updateStateLoading(changeNum?: NumericChangeId) {
     this.updateState({
-      change: reloading ? current.change : undefined,
-      loadingStatus: reloading
-        ? LoadingStatus.RELOADING
-        : LoadingStatus.LOADING,
+      change: undefined,
+      loadingStatus: changeNum
+        ? LoadingStatus.LOADING
+        : LoadingStatus.NOT_LOADED,
     });
   }
 
   // Private but used in tests.
+  /**
+   * Update the change information in the state.
+   *
+   * Since the ChangeModel must maintain consistency with ChangeViewModel
+   * The update is only allowed, if the new change has the same number as the
+   * current change or if the current change is not set (it was reset to
+   * undefined when ChangeViewModel.changeNum updated).
+   */
   updateStateChange(change?: ParsedChangeInfo) {
+    if (this.change && change?._number !== this.change?._number) {
+      return;
+    }
+    change = updateRevisionsWithCommitShas(change);
     this.updateState({
       change,
       loadingStatus:
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index f074ac3..e6175c0 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {Subject} from 'rxjs';
 import {ChangeStatus} from '../../constants/constants';
 import '../../test/common-test-setup';
@@ -23,6 +24,7 @@
 } from '../../test/test-utils';
 import {
   BasePatchSetNum,
+  ChangeInfo,
   CommitId,
   EDIT,
   NumericChangeId,
@@ -101,6 +103,7 @@
   let changeViewModel: ChangeViewModel;
   let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
+  let knownChangeNoRevision: ChangeInfo;
   const testCompleted = new Subject<void>();
 
   async function waitForLoadingStatus(
@@ -123,15 +126,19 @@
       testResolver(pluginLoaderToken),
       getAppContext().reportingService
     );
-    knownChange = {
+    knownChangeNoRevision = {
       ...createChange(),
+      status: ChangeStatus.NEW,
+      current_revision_number: 2 as PatchSetNumber,
+      messages: [],
+    };
+    knownChange = {
+      ...knownChangeNoRevision,
       revisions: {
         sha1: {...createRevision(1), description: 'patch 1'},
         sha2: {...createRevision(2), description: 'patch 2'},
       },
-      status: ChangeStatus.NEW,
       current_revision: 'abc' as CommitId,
-      messages: [],
     };
   });
 
@@ -306,11 +313,11 @@
 
     // Reloading same change
     document.dispatchEvent(new CustomEvent('reload'));
-    state = await waitForLoadingStatus(LoadingStatus.RELOADING);
+    state = await waitForLoadingStatus(LoadingStatus.LOADING);
     assert.equal(stub.callCount, 3);
     assert.equal(stub.getCall(1).firstArg, undefined);
     assert.equal(stub.getCall(2).firstArg, TEST_NUMERIC_CHANGE_ID);
-    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
+    assert.deepEqual(state?.change, undefined);
 
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
@@ -377,7 +384,7 @@
   });
 
   test('changeModel.fetchChangeUpdates on latest', async () => {
-    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+    stubRestApi('getChange').returns(Promise.resolve(knownChangeNoRevision));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.isNotOk(result.newStatus);
@@ -386,17 +393,10 @@
 
   test('changeModel.fetchChangeUpdates not on latest', async () => {
     const actualChange = {
-      ...knownChange,
-      revisions: {
-        ...knownChange.revisions,
-        sha3: {
-          ...createRevision(3),
-          description: 'patch 3',
-          _number: 3 as PatchSetNumber,
-        },
-      },
+      ...knownChangeNoRevision,
+      current_revision_number: 3 as PatchSetNumber,
     };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    stubRestApi('getChange').returns(Promise.resolve(actualChange));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isFalse(result.isLatest);
     assert.isNotOk(result.newStatus);
@@ -405,10 +405,10 @@
 
   test('changeModel.fetchChangeUpdates new status', async () => {
     const actualChange = {
-      ...knownChange,
+      ...knownChangeNoRevision,
       status: ChangeStatus.MERGED,
     };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    stubRestApi('getChange').returns(Promise.resolve(actualChange));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.equal(result.newStatus, ChangeStatus.MERGED);
@@ -417,10 +417,10 @@
 
   test('changeModel.fetchChangeUpdates new messages', async () => {
     const actualChange = {
-      ...knownChange,
+      ...knownChangeNoRevision,
       messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
     };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    stubRestApi('getChange').returns(Promise.resolve(actualChange));
     const result = await changeModel.fetchChangeUpdates(knownChange);
     assert.isTrue(result.isLatest);
     assert.isNotOk(result.newStatus);
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts
index 1890639..f6fe242 100644
--- a/polygerrit-ui/app/models/checks/checks-fakes.ts
+++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -5,6 +5,7 @@
  */
 import {
   Action,
+  ActionResult,
   Category,
   Link,
   LinkIcon,
@@ -62,6 +63,16 @@
           primary: false,
           callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
         },
+        {
+          name: 'useful',
+          callback: () =>
+            Promise.resolve({message: 'fake "useful report" triggered'}),
+        },
+        {
+          name: 'not-useful',
+          callback: () =>
+            Promise.resolve({message: 'fake "not useful report" triggered'}),
+        },
       ],
       tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
       links: [
@@ -90,7 +101,7 @@
   checkName: 'FAKE Super Check',
   startedTimestamp: new Date(new Date().getTime() - 5 * 60 * 1000),
   finishedTimestamp: new Date(new Date().getTime() + 5 * 60 * 1000),
-  patchset: 1,
+  patchset: 3,
   labelName: 'Verified',
   isSingleAttempt: true,
   isLatestAttempt: true,
@@ -159,32 +170,56 @@
     {
       internalResultId: 'f1r2',
       category: Category.ERROR,
-      summary: 'Suspicious Date',
-      message: 'That was a holiday, you know.',
+      summary: 'Test Size Checker',
+      message: 'The test seems to be of large size, not medium.',
       codePointers: [
         {
-          path: '/COMMIT_MSG',
+          path: 'plugins/BUILD',
           range: {
-            start_line: 3,
-            start_character: 0,
-            end_line: 3,
-            end_character: 0,
+            start_line: 186,
+            start_character: 12,
+            end_line: 186,
+            end_character: 18,
           },
         },
       ],
+      actions: [
+        {
+          name: 'useful',
+          tooltip: 'This check result was helpful',
+          callback: () =>
+            new Promise(resolve => {
+              setTimeout(
+                () => resolve({message: 'Feedback recorded.'} as ActionResult),
+                1000
+              );
+            }),
+        },
+        {
+          name: 'not-useful',
+          tooltip: 'This check result was not helpful',
+          callback: () =>
+            new Promise(resolve => {
+              setTimeout(
+                () => resolve({message: 'Feedback recorded.'} as ActionResult),
+                1000
+              );
+            }),
+        },
+      ],
       fixes: [
         {
           description: 'This is the way to do it.',
           replacements: [
             {
-              path: 'BUILD',
+              path: 'plugins/BUILD',
               range: {
-                start_line: 1,
-                start_character: 0,
-                end_line: 1,
-                end_character: 0,
+                start_line: 186,
+                start_character: 12,
+                end_line: 186,
+                end_character: 18,
               },
-              replacement: '# This is now fixed.\n',
+              replacement: 'large',
             },
           ],
         },
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 2979912..66a8bd3 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -478,6 +478,10 @@
       scheduledCount: 0,
       runningCount: 0,
       completedCount: 0,
+      errorWithFixCount: 0,
+      errorWithoutFixCount: 0,
+      warningWithFixCount: 0,
+      warningWithoutFixCount: 0,
     };
     const providers = Object.values(state);
     for (const provider of providers) {
@@ -492,8 +496,22 @@
         if (run.status === RunStatus.RUNNING) stats.runningCount++;
         if (run.status === RunStatus.COMPLETED) stats.completedCount++;
         for (const result of run.results ?? []) {
-          if (result.category === Category.ERROR) stats.errorCount++;
-          if (result.category === Category.WARNING) stats.warningCount++;
+          if (result.category === Category.ERROR) {
+            stats.errorCount++;
+            if (result.fixes?.[0]) {
+              stats.errorWithFixCount++;
+            } else {
+              stats.errorWithoutFixCount++;
+            }
+          }
+          if (result.category === Category.WARNING) {
+            stats.warningCount++;
+            if (result.fixes?.[0]) {
+              stats.warningWithFixCount++;
+            } else {
+              stats.warningWithoutFixCount++;
+            }
+          }
           if (result.category === Category.INFO) stats.infoCount++;
           if (result.category === Category.SUCCESS) stats.successCount++;
         }
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 767e604..fdaacd2 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import './checks-model';
 import {
@@ -30,12 +31,16 @@
 } from '../../test/test-data-generators';
 import {waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {ParsedChangeInfo} from '../../types/types';
-import {changeModelToken} from '../change/change-model';
+import {
+  changeModelToken,
+  updateRevisionsWithCommitShas,
+} from '../change/change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
 import {changeViewModelToken} from '../views/change';
 import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
 import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {deepEqual} from '../../utils/deep-util';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -106,17 +111,17 @@
     });
     await waitUntil(() => change === undefined);
 
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
     await waitUntilCalled(fetchSpy, 'fetch');
 
     assert.equal(
       model.latestPatchNum,
-      testChange.revisions[testChange.current_revision]
+      testChange!.revisions[testChange!.current_revision]
         ._number as PatchSetNumber
     );
-    assert.equal(model.changeNum, testChange._number);
+    assert.equal(model.changeNum, testChange!._number);
   });
 
   test('fetch throttle', async () => {
@@ -133,9 +138,9 @@
     });
     await waitUntil(() => change === undefined);
 
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
 
     model.reload('test-plugin');
     model.reload('test-plugin');
@@ -339,9 +344,9 @@
     });
     await waitUntil(() => change === undefined);
     clock.tick(1);
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
     clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
     const pollCount = fetchSpy.callCount;
@@ -366,9 +371,9 @@
     });
     await waitUntil(() => change === undefined);
     clock.tick(1);
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
     clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
     clock.tick(1);
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index bb5030c..5eb9cac 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -54,6 +54,8 @@
       return {name: 'code'};
     case LinkIcon.FILE_PRESENT:
       return {name: 'file_present'};
+    case LinkIcon.VIEW_TIMELINE:
+      return {name: 'view_timeline'};
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
@@ -136,16 +138,18 @@
 
 export function rectifyFix(
   fix: Fix | undefined,
-  checkName: string
+  checkName: string | undefined
 ): FixSuggestionInfo | undefined {
-  if (!fix?.replacements) return undefined;
+  if (!fix?.replacements || !checkName) return undefined;
   const replacements = fix.replacements
     .map(rectifyReplacement)
     .filter(isDefined);
   if (replacements.length === 0) return undefined;
 
   return {
-    description: fix.description ?? `Fix provided by ${checkName}`,
+    description: [fix.description, `Fix provided by ${checkName}`]
+      .filter(Boolean)
+      .join(' - '),
     fix_id: PROVIDED_FIX_ID,
     replacements,
   };
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index ead7dd01..3b5c108 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -93,6 +93,29 @@
     assert.equal(rectified?.fix_id, PROVIDED_FIX_ID);
   });
 
+  test('rectifyFix changes description when description is empty', () => {
+    const rectified = rectifyFix(
+      {
+        replacements: [
+          {
+            path: 'test-path',
+            range: {
+              start_line: 1,
+              end_line: 1,
+              start_character: 0,
+              end_character: 1,
+            } as CommentRange,
+            replacement: 'test-replacement-string',
+          },
+        ],
+        description: '',
+      },
+      'test-check-name'
+    );
+    assert.isDefined(rectified);
+    assert.equal(rectified?.description, 'Fix provided by test-check-name');
+  });
+
   test('sortAttemptChoices', () => {
     const unsorted: (AttemptChoice | undefined)[] = [
       3,
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index f26a565..4b39522 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -7,7 +7,6 @@
 import {
   CommentInfo,
   NumericChangeId,
-  PatchSetNum,
   RevisionId,
   UrlEncodedCommentId,
   RobotCommentInfo,
@@ -20,21 +19,34 @@
   isError,
   isDraft,
   isNew,
+  PatchSetNumber,
+  CommentThread,
 } from '../../types/common';
 import {
   addPath,
   convertToCommentInput,
   createNew,
   createNewPatchsetLevel,
+  getFirstComment,
+  hasSuggestion,
+  hasUserSuggestion,
   id,
   isDraftThread,
   isNewThread,
+  isUnresolved,
   reportingDetails,
 } from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
 import {select} from '../../utils/observable-util';
 import {define} from '../dependency';
-import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
+import {
+  BehaviorSubject,
+  combineLatest,
+  forkJoin,
+  from,
+  Observable,
+  of,
+} from 'rxjs';
 import {fire, fireAlert} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
@@ -47,7 +59,7 @@
 import {Deduping} from '../../api/reporting';
 import {extractMentionedUsers, getUserId} from '../../utils/account-util';
 import {SpecialFilePath} from '../../constants/constants';
-import {AccountsModel} from '../accounts-model/accounts-model';
+import {AccountsModel} from '../accounts/accounts-model';
 import {
   distinctUntilChanged,
   map,
@@ -69,9 +81,20 @@
   drafts?: {[path: string]: DraftInfo[]};
   // Ported comments only affect `CommentThread` properties, not individual
   // comments.
-  /** undefined means 'still loading' */
+  /**
+   * Comments ported from earlier patchsets.
+   *
+   * This only considers current patchset (right side), not the base patchset
+   * (left-side).
+   *
+   * undefined means 'still loading'
+   */
   portedComments?: {[path: string]: CommentInfo[]};
-  /** undefined means 'still loading' */
+  /**
+   * Drafts ported from earlier patchsets.
+   *
+   * undefined means 'still loading'
+   */
   portedDrafts?: {[path: string]: DraftInfo[]};
   /**
    * If a draft is discarded by the user, then we temporarily keep it in this
@@ -216,7 +239,7 @@
   return nextState;
 }
 
-/** Adds or updates a draft. */
+/** Adds or updates a draft in the state. */
 export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
   const nextState = {...state};
   assert(!!draft.path, 'draft without path');
@@ -235,6 +258,12 @@
   return nextState;
 }
 
+/** Removes a draft from the state.
+ *
+ * Removed draft is stored in discardedDrafts for potential undo operation.
+ * discardedDrafts however is only a client-side cache and such drafts are not
+ * retained in the server.
+ */
 export function deleteDraft(
   state: CommentState,
   draft: DraftInfo
@@ -254,6 +283,10 @@
 }
 
 export const commentsModelToken = define<CommentsModel>('comments-model');
+/**
+ * Model that maintains the state of all comments and drafts for the current
+ * change in the context of change-view.
+ */
 export class CommentsModel extends Model<CommentState> {
   public readonly commentsLoading$ = select(
     this.state$,
@@ -402,6 +435,17 @@
     threads.filter(t => !isNewThread(t) && isDraftThread(t))
   );
 
+  public readonly threadsWithUnappliedSuggestions$ = select(
+    combineLatest([this.threads$, this.changeModel.latestPatchNum$]),
+    ([threads, latestPs]) =>
+      threads.filter(
+        t =>
+          isUnresolved(t) &&
+          hasSuggestion(t) &&
+          getFirstComment(t)?.patch_set === latestPs
+      )
+  );
+
   public readonly commentedPaths$ = select(
     combineLatest([
       this.changeComments$,
@@ -415,6 +459,8 @@
     }
   );
 
+  public readonly reloadAllComments$ = new BehaviorSubject(undefined);
+
   public thread$(id: UrlEncodedCommentId) {
     return select(this.threads$, threads => threads.find(t => t.rootId === id));
   }
@@ -423,8 +469,6 @@
 
   private changeNum?: NumericChangeId;
 
-  private patchNum?: PatchSetNum;
-
   private drafts: {[path: string]: DraftInfo[]} = {};
 
   private draftToastTask?: DelayedTask;
@@ -464,9 +508,8 @@
     this.subscriptions.push(
       this.drafts$.subscribe(x => (this.drafts = x ?? {}))
     );
-    this.subscriptions.push(
-      this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
-    );
+    // Patchset-level draft should always exist when opening reply dialog.
+    // If there are none, create an empty one.
     this.subscriptions.push(
       combineLatest([
         this.draftsLoading$,
@@ -478,41 +521,71 @@
       })
     );
     this.subscriptions.push(
-      this.changeViewModel.changeNum$.subscribe(changeNum => {
-        this.changeNum = changeNum;
-        this.setState({...initialState});
-        this.reloadAllComments();
+      combineLatest([this.changeViewModel.changeNum$, this.reloadAllComments$])
+        .pipe(
+          switchMap(([changeNum, _]) => {
+            this.changeNum = changeNum;
+            this.setState({...initialState});
+            if (!changeNum) return of([undefined, undefined, undefined]);
+            return forkJoin([
+              this.restApiService.getDiffComments(changeNum),
+              this.restApiService.getDiffRobotComments(changeNum),
+              this.restApiService.getDiffDrafts(changeNum),
+            ]);
+          })
+        )
+        .subscribe(([comments, robotComments, drafts]) => {
+          this.reportRobotCommentStats(robotComments);
+          this.modifyState(s => {
+            s = setComments(s, comments);
+            s = setRobotComments(s, robotComments);
+            return setDrafts(s, drafts);
+          });
+        })
+    );
+    // When the patchset selection changes update information about comments
+    // ported from earlier patchsets.
+    this.subscriptions.push(
+      combineLatest([this.changeModel.changeNum$, this.changeModel.patchNum$])
+        .pipe(
+          switchMap(([changeNum, patchNum]) => {
+            this.changeNum = changeNum;
+            if (!changeNum) return of([undefined, undefined]);
+            const revision = patchNum ?? (CURRENT as RevisionId);
+            return forkJoin([
+              this.restApiService.getPortedComments(changeNum, revision),
+              this.restApiService.getPortedDrafts(changeNum, revision),
+            ]);
+          })
+        )
+        .subscribe(([portedComments, portedDrafts]) =>
+          this.modifyState(s => {
+            s = setPortedComments(s, portedComments);
+            return setPortedDrafts(s, portedDrafts);
+          })
+        )
+    );
+    this.subscriptions.push(
+      combineLatest([
+        this.comments$,
+        this.changeModel.latestPatchNum$,
+      ]).subscribe(([comments, latestPatchset]) => {
+        this.reportCommentStats(comments, latestPatchset);
       })
     );
     this.subscriptions.push(
       combineLatest([
-        this.changeModel.changeNum$,
-        this.changeModel.patchNum$,
-      ]).subscribe(([changeNum, patchNum]) => {
-        this.changeNum = changeNum;
-        this.patchNum = patchNum;
-        this.reloadAllPortedComments();
+        this.threadsSaved$,
+        this.changeModel.latestPatchNum$,
+      ]).subscribe(([threads, latestPatchset]) => {
+        this.reportThreadsStats(threads, latestPatchset);
       })
     );
   }
 
   // Note that this does *not* reload ported comments.
-  async reloadAllComments() {
-    if (!this.changeNum) return;
-    await Promise.all([
-      this.reloadComments(this.changeNum),
-      this.reloadRobotComments(this.changeNum),
-      this.reloadDrafts(this.changeNum),
-    ]);
-  }
-
-  async reloadAllPortedComments() {
-    if (!this.changeNum) return;
-    if (!this.patchNum) return;
-    await Promise.all([
-      this.reloadPortedComments(this.changeNum, this.patchNum),
-      this.reloadPortedDrafts(this.changeNum, this.patchNum),
-    ]);
+  reloadAllComments() {
+    this.reloadAllComments$.next(undefined);
   }
 
   // visible for testing
@@ -520,19 +593,6 @@
     this.setState(reducer({...this.getState()}));
   }
 
-  async reloadComments(changeNum: NumericChangeId): Promise<void> {
-    const comments = await this.restApiService.getDiffComments(changeNum);
-    this.modifyState(s => setComments(s, comments));
-  }
-
-  async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
-    const robotComments = await this.restApiService.getDiffRobotComments(
-      changeNum
-    );
-    this.reportRobotCommentStats(robotComments);
-    this.modifyState(s => setRobotComments(s, robotComments));
-  }
-
   private reportRobotCommentStats(obj?: PathToRobotCommentsInfoMap) {
     if (!obj) return;
     const comments = Object.values(obj).flat();
@@ -561,31 +621,84 @@
     );
   }
 
-  async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
-    const drafts = await this.restApiService.getDiffDrafts(changeNum);
-    this.modifyState(s => setDrafts(s, drafts));
+  private reportCommentStats(
+    obj?: {[path: string]: CommentInfo[]},
+    latestPatchset?: PatchSetNumber
+  ) {
+    if (!obj || !latestPatchset) return;
+    const comments = Object.values(obj).flat();
+    if (comments.length === 0) return;
+
+    const commentsLatest = comments.filter(c => c.patch_set === latestPatchset);
+    const commentsUnresolved = comments.filter(c => c.unresolved);
+    const commentsLatestUnresolved = commentsLatest.filter(c => c.unresolved);
+
+    const hasFix = (c: CommentInfo) => (c.fix_suggestions?.length ?? 0) > 0;
+
+    const details = {
+      countLatest: commentsLatest.length,
+      countLatestWithFix: commentsLatest.filter(hasFix).length,
+      countLatestWithUserFix: commentsLatest.filter(hasUserSuggestion).length,
+      countLatestUnresolved: commentsLatestUnresolved.length,
+      countLatestUnresolvedWithFix:
+        commentsLatestUnresolved.filter(hasFix).length,
+      countLatestUnresolvedWithUserFix:
+        commentsLatestUnresolved.filter(hasUserSuggestion).length,
+      countAll: comments.length,
+      countAllUnresolved: commentsUnresolved.length,
+      countAllWithFix: comments.filter(hasFix).length,
+      countAllUnresolvedWithFix: commentsUnresolved.filter(hasFix).length,
+      countAllWithUserFix: comments.filter(hasUserSuggestion).length,
+      countAllUnresolvedWithUserFix: comments.filter(
+        c => c.unresolved && hasUserSuggestion(c)
+      ).length,
+    };
+    this.reporting.reportInteraction(Interaction.COMMENTS_STATS, details, {
+      deduping: Deduping.EVENT_ONCE_PER_CHANGE,
+    });
   }
 
-  async reloadPortedComments(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    const portedComments = await this.restApiService.getPortedComments(
-      changeNum,
-      patchNum
-    );
-    this.modifyState(s => setPortedComments(s, portedComments));
-  }
+  private reportThreadsStats(
+    threads?: CommentThread[],
+    latestPatchset?: PatchSetNumber
+  ) {
+    if (!threads || !latestPatchset) return;
+    if (threads.length === 0) return;
 
-  async reloadPortedDrafts(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    const portedDrafts = await this.restApiService.getPortedDrafts(
-      changeNum,
-      patchNum
+    const threadsLatest = threads.filter(
+      t => getFirstComment(t)?.patch_set === latestPatchset
     );
-    this.modifyState(s => setPortedDrafts(s, portedDrafts));
+    const threadsUnresolved = threads.filter(isUnresolved);
+    const commentsLatestUnresolved = threadsLatest.filter(isUnresolved);
+
+    const hasFix = (t: CommentThread) =>
+      (getFirstComment(t)?.fix_suggestions?.length ?? 0) > 0;
+
+    const hasUserFix = (t: CommentThread) => {
+      const firstComment = getFirstComment(t);
+      return firstComment && hasUserSuggestion(firstComment);
+    };
+
+    const details = {
+      countLatest: threadsLatest.length,
+      countLatestWithFix: threadsLatest.filter(hasFix).length,
+      countLatestWithUserFix: threadsLatest.filter(hasUserFix).length,
+      countLatestUnresolved: commentsLatestUnresolved.length,
+      countLatestUnresolvedWithFix:
+        commentsLatestUnresolved.filter(hasFix).length,
+      countLatestUnresolvedWithUserFix:
+        commentsLatestUnresolved.filter(hasUserFix).length,
+      countAll: threads.length,
+      countAllUnresolved: threadsUnresolved.length,
+      countAllWithFix: threads.filter(hasFix).length,
+      countAllUnresolvedWithFix: threadsUnresolved.filter(hasFix).length,
+      countAllWithUserFix: threads.filter(hasUserFix).length,
+      countAllUnresolvedWithUserFix:
+        threadsUnresolved.filter(hasUserFix).length,
+    };
+    this.reporting.reportInteraction(Interaction.THREADS_STATS, details, {
+      deduping: Deduping.EVENT_ONCE_PER_CHANGE,
+    });
   }
 
   async restoreDraft(draftId: UrlEncodedCommentId) {
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 0d3df42..b3dbc08 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -28,7 +28,7 @@
 import {changeModelToken} from '../change/change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
-import {accountsModelToken} from '../accounts-model/accounts-model';
+import {accountsModelToken} from '../accounts/accounts-model';
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {changeViewModelToken} from '../views/change';
 import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
diff --git a/polygerrit-ui/app/models/config/config-model_test.ts b/polygerrit-ui/app/models/config/config-model_test.ts
index b78a933..bb6af87 100644
--- a/polygerrit-ui/app/models/config/config-model_test.ts
+++ b/polygerrit-ui/app/models/config/config-model_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import {assert} from '@open-wc/testing';
 import {getBaseUrl} from '../../utils/url-util';
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 8e9bead..880bcd0 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -89,6 +89,11 @@
 
   public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
 
+  public suggestionsPlugins$ = select(
+    this.state$,
+    state => state.suggestionsPlugins
+  );
+
   public pluginsLoaded$ = select(this.state$, state => state.pluginsLoaded);
 
   constructor() {
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index cd6a66a..4973307 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {from, of, Observable} from 'rxjs';
-import {filter, switchMap} from 'rxjs/operators';
+import {filter, switchMap, tap} from 'rxjs/operators';
 import {
   DiffPreferencesInfo as DiffPreferencesInfoAPI,
   DiffViewMode,
@@ -13,6 +13,7 @@
   AccountCapabilityInfo,
   AccountDetailInfo,
   EditPreferencesInfo,
+  EmailInfo,
   PreferencesInfo,
   TopMenuItemInfo,
 } from '../../types/common';
@@ -48,6 +49,7 @@
    * `account` is known, then use `accountLoaded` below.
    */
   account?: AccountDetailInfo;
+  emails?: EmailInfo[];
   /**
    * Starts as `false` and switches to `true` after the first `getAccount` call.
    * A common use case for this is to wait with loading or doing something until
@@ -82,6 +84,15 @@
     userState => userState.account
   );
 
+  readonly emails$: Observable<EmailInfo[] | undefined> = select(
+    this.state$,
+    userState => userState.emails
+  ).pipe(
+    tap(emails => {
+      if (emails === undefined) this.loadEmails();
+    })
+  );
+
   /**
    * Only emits once we have tried to actually load the account. Note that
    * this does not initially emit a value.
@@ -148,12 +159,8 @@
     super({
       accountLoaded: false,
     });
+    this.loadAccount();
     this.subscriptions = [
-      from(this.restApiService.getAccount()).subscribe(
-        (account?: AccountDetailInfo) => {
-          this.setAccount(account);
-        }
-      ),
       this.loadedAccount$
         .pipe(
           switchMap(account => {
@@ -261,4 +268,22 @@
   setAccount(account?: AccountDetailInfo) {
     this.updateState({account, accountLoaded: true});
   }
+
+  private setAccountEmails(emails?: EmailInfo[]) {
+    this.updateState({emails});
+  }
+
+  loadAccount(noCache?: boolean) {
+    if (noCache) this.restApiService.invalidateAccountsDetailCache();
+    return this.restApiService.getAccount().then(account => {
+      this.setAccount(account);
+    });
+  }
+
+  loadEmails(noCache?: boolean) {
+    if (noCache) this.restApiService.invalidateAccountsEmailCache();
+    return this.restApiService.getAccountEmails().then(emails => {
+      this.setAccountEmails(emails);
+    });
+  }
 }
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 02b5796..164858d 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -59,10 +59,22 @@
   },
 };
 
+export const SERVER_INFO_ROUTE: Route<AdminViewState> = {
+  urlPattern: /^\/admin\/server-info$/,
+  createState: () => {
+    const state: AdminViewState = {
+      view: GerritView.ADMIN,
+      adminView: AdminChildView.SERVER_INFO,
+    };
+    return state;
+  },
+};
+
 export enum AdminChildView {
   REPOS = 'gr-repo-list',
   GROUPS = 'gr-admin-group-list',
   PLUGINS = 'gr-plugin-list',
+  SERVER_INFO = 'gr-server-info',
 }
 const ADMIN_LINKS: NavLink[] = [
   {
@@ -84,6 +96,12 @@
     url: createAdminUrl({adminView: AdminChildView.PLUGINS}),
     view: 'gr-plugin-list' as GerritView,
   },
+  {
+    name: 'Server Info',
+    section: 'Server Info',
+    url: createAdminUrl({adminView: AdminChildView.SERVER_INFO}),
+    view: 'gr-server-info' as GerritView,
+  },
 ];
 
 export interface AdminLink {
@@ -277,6 +295,8 @@
       return `${getBaseUrl()}/admin/groups`;
     case AdminChildView.PLUGINS:
       return `${getBaseUrl()}/admin/plugins`;
+    case AdminChildView.SERVER_INFO:
+      return `${getBaseUrl()}/admin/server-info`;
   }
 }
 
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index 5d142bf..eab362a 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import '../../test/common-test-setup';
 import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
@@ -55,6 +56,11 @@
       assert.isNotOk(res.links[2].subsection);
     }
 
+    if (expected.serverInfoShown) {
+      assert.equal(res.links[3].name, 'Server Info');
+      assert.isNotOk(res.links[3].subsection);
+    }
+
     if (expected.projectPageShown) {
       assert.isOk(res.links[0].subsection);
       assert.equal(res.links[0].subsection!.children!.length, 6);
@@ -116,6 +122,7 @@
         groupListShown: false,
         groupPageShown: false,
         pluginListShown: false,
+        serverInfoShown: false,
       };
     });
 
@@ -162,7 +169,7 @@
 
     setup(() => {
       expected = {
-        totalLength: 2,
+        totalLength: 3,
         pluginListShown: false,
       };
       capabilityStub.returns(Promise.resolve({}));
@@ -203,9 +210,10 @@
     setup(() => {
       capabilityStub.returns(Promise.resolve({viewPlugins: true}));
       expected = {
-        totalLength: 3,
+        totalLength: 4,
         groupListShown: true,
         pluginListShown: true,
+        serverInfoShown: true,
       };
     });
 
@@ -312,7 +320,7 @@
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
-        totalLength: 4,
+        totalLength: 5,
         pluginGeneratedLinks: generatedLinks,
       });
       await testAdminLinks(account, options, expected);
@@ -339,7 +347,7 @@
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
-        totalLength: 3,
+        totalLength: 4,
         pluginGeneratedLinks: [generatedLinks[0]],
       });
       await testAdminLinks(account, options, expected);
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 06d981a..661c74f 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -11,11 +11,12 @@
   ChangeInfo,
   PatchSetNumber,
   EDIT,
+  PARENT,
 } from '../../api/rest-api';
 import {Tab} from '../../constants/constants';
 import {GerritView} from '../../services/router/router-model';
 import {UrlEncodedCommentId} from '../../types/common';
-import {toggleSet} from '../../utils/common-util';
+import {assertIsDefined, toggleSet} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
 import {
   encodeURL,
@@ -26,6 +27,7 @@
 import {define} from '../dependency';
 import {Model} from '../base/model';
 import {ViewState} from './base';
+import {isNumber} from '../../utils/patch-set-util';
 
 export enum ChangeChildView {
   OVERVIEW = 'OVERVIEW',
@@ -85,7 +87,7 @@
 
   /** These properties apply to the DIFF child view only. */
   diffView?: {
-    path?: string;
+    path: string;
     // TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
     lineNum?: number;
     leftSide?: boolean;
@@ -93,11 +95,28 @@
 
   /** These properties apply to the EDIT child view only. */
   editView?: {
-    path?: string;
+    path: string;
     lineNum?: number;
   };
 }
 
+export type DiffViewState = Partial<ChangeViewState> & {
+  patchNum: RevisionPatchSetNum;
+  diffView: {
+    path: string;
+    lineNum?: number;
+    leftSide?: boolean;
+  };
+};
+
+export type EditViewState = Partial<ChangeViewState> & {
+  patchNum: RevisionPatchSetNum;
+  editView: {
+    path: string;
+    lineNum?: number;
+  };
+};
+
 /**
  * This is a convenience type such that you can pass a `ChangeInfo` object
  * as the `change` property instead of having to set both the `changeNum` and
@@ -145,7 +164,7 @@
 
 export function createChangeUrl(
   obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
   const state: ChangeViewState = objToState({
     ...obj,
     childView: ChangeChildView.OVERVIEW,
@@ -198,7 +217,7 @@
 
 export function createDiffUrl(
   obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
   const state: ChangeViewState = objToState({
     ...obj,
     childView: ChangeChildView.DIFF,
@@ -378,6 +397,49 @@
     }
   }
 
+  /**
+   * Wrapper around createDiffUrl() that falls back to the current state for all
+   * properties that are not explicitly provided as an override.
+   */
+  diffUrl(override: DiffViewState): string {
+    const current = this.getState();
+    assertIsDefined(current?.changeNum);
+    assertIsDefined(current?.repo);
+
+    const patchNum = override.patchNum ?? current.patchNum;
+    let basePatchNum = override.basePatchNum ?? current.basePatchNum;
+    if (isNumber(basePatchNum) && isNumber(patchNum)) {
+      if ((patchNum as number) <= (basePatchNum as number)) {
+        basePatchNum = PARENT;
+      }
+    }
+    return createDiffUrl({
+      changeNum: override.changeNum ?? current.changeNum,
+      repo: override.repo ?? current.repo,
+      patchNum,
+      basePatchNum,
+      checksPatchset: override.checksPatchset ?? current.checksPatchset,
+      diffView: override.diffView ?? current.diffView,
+    });
+  }
+
+  /**
+   * Wrapper around createEditUrl() that falls back to the current state for all
+   * properties that are not explicitly provided as an override.
+   */
+  editUrl(override: EditViewState): string {
+    const current = this.getState();
+    assertIsDefined(current?.changeNum);
+    assertIsDefined(current?.repo);
+
+    return createEditUrl({
+      changeNum: override.changeNum ?? current.changeNum,
+      repo: override.repo ?? current.repo,
+      patchNum: override.patchNum ?? current.patchNum,
+      editView: override.editView ?? current.editView,
+    });
+  }
+
   toggleSelectedCheckRun(checkName: string) {
     const current = this.getState()?.checksRunsSelected ?? new Set();
     const next = new Set(current);
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
index ed6de419..0b0594f 100644
--- a/polygerrit-ui/app/models/views/search_test.ts
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import {SinonStubbedMember} from 'sinon';
 import {
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 9fa2e89..21fab53 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -454,6 +454,14 @@
     },
   },
   {
+    name: 'highlightjs-epp',
+    license: {
+      name: 'highlightjs-epp',
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: 'LICENSE',
+    },
+  },
+  {
     name: 'highlightjs-structured-text',
     license: {
       name: 'highlightjs-structured-text',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 9004eec..d2b5283 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -29,28 +29,34 @@
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/paper-tooltip": "^3.0.1",
-    "@polymer/polymer": "^3.5.1",
+    "@polymer/polymer": "3.5.1",
     "@types/resemblejs": "^4.1.3",
     "@types/resize-observer-browser": "^0.1.11",
     "@webcomponents/shadycss": "^1.11.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "highlight.js": "^11.9.0",
-    "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
-    "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
+    "highlight.js": "^11.10.0",
+    "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba",
+    "highlightjs-epp": "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9",
+    "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e",
     "immer": "^9.0.21",
-    "lit": "^3.1.0",
+    "lit": "^3.2.1",
     "polymer-bridges": "file:../../polymer-bridges",
     "polymer-resin": "^2.0.1",
     "resemblejs": "^5.0.0",
     "rxjs": "^6.6.7",
     "safevalues": "0.3.1",
-    "web-vitals": "^3.5.1"
+    "web-vitals": "^3.5.2"
   },
   "dependencies // comments": {
     "safevalues": [
       "There is a an issue with release 0.3.2, which exposes both an ESM and a CommonJS module:",
       "https://github.com/google/safevalues/commit/16aa2567dc303759841b097b1901d1d6ff4e083e",
       "That causes tests to fail claiming that 'sanitizeHtml' is not exported from safevalues."
+    ],
+    "@polymer/polymer": [
+      "There is a an issue with release 3.5.2. Tests are failing with:",
+      "NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':",
+      "the name 'dom-module' has already been used with this registry at ..."
     ]
   },
   "license": "Apache-2.0",
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 9ab0f64..bdfb870 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,5 +1,5 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("//tools/bzl:genrule2.bzl", "genrule2")
 
 def polygerrit_bundle(name, srcs, outs, entry_point, app_name):
     """Build .zip bundle from source code
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 40517ac..8536492 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -34,7 +34,7 @@
 import {
   AccountsModel,
   accountsModelToken,
-} from '../models/accounts-model/accounts-model';
+} from '../models/accounts/accounts-model';
 import {
   DashboardViewModel,
   dashboardViewModelToken,
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 4a1fc29..750b421 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -19,8 +19,8 @@
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
-  ML_SUGGESTED_EDIT = 'UiFeature__ml_suggested_edit',
-  ML_SUGGESTED_EDIT_V2 = 'UiFeature__ml_suggested_edit_v2',
   REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data',
-  SIMPLIFIED_DIFF_PROCESSOR = 'UiFeature__simplified_diff_processor',
+  COMMENT_AUTOCOMPLETION = 'UiFeature__comment_autocompletion_enabled',
+  SAVE_PROJECT_CONFIG_FOR_REVIEW = 'UiFeature__save_project_config_for_review',
+  PARALLEL_DASHBOARD_REQUESTS = 'UiFeature__parallel_dashboard_requests',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
index f50921e..28be742 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import {Auth, AuthStatus} from './gr-auth_impl';
 import {SinonFakeTimers} from 'sinon';
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 6df2c67..e175228 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -55,7 +55,7 @@
   /**
    * Finish named timer and report it to server.
    */
-  timeEnd(name: Timing, eventDetails?: EventDetails): void;
+  timeEnd(name: Timing, eventDetails?: EventDetails): number;
   /**
    * Get a timer object for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 08c9e36..af83699 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -3,7 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {FlagsService} from '../flags/flags';
+import {FlagsService, KnownExperimentId} from '../flags/flags';
 import {EventValue, ReportingService, Timer} from './gr-reporting';
 import {hasOwnProperty} from '../../utils/common-util';
 import {NumericChangeId} from '../../types/common';
@@ -15,7 +15,7 @@
   LifeCycle,
   Timing,
 } from '../../constants/reporting';
-import {onCLS, onFID, onLCP, Metric, onINP} from 'web-vitals';
+import {onCLS, onLCP, Metric, onINP} from 'web-vitals';
 import {getEventPath, isElementTarget} from '../../utils/dom-util';
 import {Finalizable} from '../../types/types';
 
@@ -290,7 +290,6 @@
   }
 
   onCLS(metric => reportWebVitalMetric(Timing.CLS, metric));
-  onFID(metric => reportWebVitalMetric(Timing.FID, metric));
   onLCP(metric => reportWebVitalMetric(Timing.LCP, metric));
   onINP(metric => reportWebVitalMetric(Timing.INP, metric));
 }
@@ -360,6 +359,7 @@
   screenSize?: {width: number; height: number};
   viewport?: {width: number; height: number};
   usedJSHeapSizeMb?: number;
+  parallelRequestsEnabled?: boolean;
 }
 
 interface SlowRpcCall {
@@ -687,6 +687,9 @@
     const details: PageLoadDetails = {
       rpcList: this.slowRpcSnapshot,
       hiddenDurationMs: this.hiddenDurationTimer.accHiddenDurationMs,
+      parallelRequestsEnabled: this._flagsService.isEnabled(
+        KnownExperimentId.PARALLEL_DASHBOARD_REQUESTS
+      ),
     };
 
     if (window.screen) {
@@ -759,28 +762,37 @@
   time(name: Timing) {
     this._baselines[name] = now();
     window.performance.mark(`${name}-start`);
+    // When time(Timing.DASHBOARD_DISPLAYED) is called gr-dashboard-view
+    // we need to clean-up slowRpcList, otherwise it can accumulate to big size
+    if (name === Timing.DASHBOARD_DISPLAYED) {
+      this.slowRpcList = [];
+      this.hiddenDurationTimer.reset();
+    }
   }
 
   /**
    * Finish named timer and report it to server.
    */
-  timeEnd(name: Timing, eventDetails?: EventDetails) {
+  timeEnd(name: Timing, eventDetails?: EventDetails): number {
     if (!hasOwnProperty(this._baselines, name)) {
-      return;
+      return 0;
     }
-    const baseTime = this._baselines[name];
+    const begin = this._baselines[name];
     delete this._baselines[name];
-    this._reportTiming(name, now() - baseTime, eventDetails);
+    const end = now();
+    const elapsed = end - begin;
+    this._reportTiming(name, elapsed, eventDetails);
 
     // Finalize the interval. Either from a registered start mark or
     // the navigation start time (if baseTime is 0).
-    if (baseTime !== 0) {
+    if (begin !== 0) {
       window.performance.measure(name, `${name}-start`);
     } else {
       // Microsoft Edge does not handle the 2nd param correctly
       // (if undefined).
       window.performance.measure(name);
     }
+    return elapsed;
   }
 
   /**
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index fb1f0c3..7cb777a 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -71,5 +71,5 @@
   setRepoName: () => {},
   setChangeId: () => {},
   time: () => {},
-  timeEnd: () => {},
+  timeEnd: () => 0,
 };
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
index 2d3dfe2..86e1cd1 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import {
   GrReporting,
@@ -174,6 +175,7 @@
         },
         usedJSHeapSizeMb: 1,
         hiddenDurationMs: 0,
+        parallelRequestsEnabled: false,
       })
     );
   });
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 2b5842a..a65eddc 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -8,7 +8,6 @@
 import {GrEtagDecorator} from '../../elements/shared/gr-rest-api-interface/gr-etag-decorator';
 import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {parseDate} from '../../utils/date-util';
-import {getBaseUrl} from '../../utils/url-util';
 import {getParentIndex, isMergeParent} from '../../utils/patch-set-util';
 import {listChangesOptionsToHex} from '../../utils/change-util';
 import {assertNever, hasOwnProperty} from '../../utils/common-util';
@@ -19,6 +18,7 @@
   AccountExternalIdInfo,
   AccountId,
   AccountInfo,
+  AccountStateInfo,
   ActionNameToActionInfoMap,
   Base64File,
   Base64FileContent,
@@ -348,6 +348,22 @@
     });
   }
 
+  saveRepoConfigForReview(
+    repo: RepoName,
+    config: ConfigInput
+  ): Promise<ChangeInfo | undefined> {
+    const url = `/projects/${encodeURIComponent(repo)}/config:review`;
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: config,
+      }),
+      url,
+      anonymizedUrl: '/projects/*/config',
+      reportServerError: true,
+    }) as unknown as Promise<ChangeInfo | undefined>;
+  }
+
   runRepoGC(repo: RepoName): Promise<Response> {
     const encodeName = encodeURIComponent(repo);
     return this._restApiHelper.fetch({
@@ -664,6 +680,9 @@
   savePreferences(
     prefs: PreferencesInput
   ): Promise<PreferencesInfo | undefined> {
+    // Invalidate the cache.
+    this._cache.delete('/accounts/self/preferences');
+
     // Note (Issue 5142): normalize the download scheme with lower case before
     // saving.
     if (prefs.download_scheme) {
@@ -858,7 +877,10 @@
     const cachedAccount = this._cache.get('/accounts/self/detail');
     if (cachedAccount) {
       // Replace object in cache with new object to force UI updates.
-      this._cache.set('/accounts/self/detail', {...cachedAccount, ...obj});
+      this._cache.set('/accounts/self/detail', {
+        ...cachedAccount,
+        ...obj,
+      });
     }
   }
 
@@ -1037,6 +1059,13 @@
     });
   }
 
+  getAccountState(): Promise<AccountStateInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/state',
+      reportUrlAsIs: true,
+    }) as Promise<AccountStateInfo | undefined>;
+  }
+
   getWatchedProjects() {
     return this._restApiHelper.fetchCacheJSON({
       url: '/accounts/self/watched.projects',
@@ -1104,6 +1133,42 @@
   }
 
   /**
+   * Depending on an experiment this will either use `getChangesForMultipleQueries()`, which
+   * makes just one request to the REST API. Or it will fan out into multiple parallel
+   * requests and call `getChanges()` for each query.
+   */
+  async getChangesForDashboard(
+    changesPerPage?: number,
+    queries?: string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[][] | undefined> {
+    // CAUTION: Before actually enabling this experiment for everyone we will have to also change
+    // the prefetched query in the backend. As is the experiment may help improving the
+    // DashboardDisplayed metric, but it will definitely make the *Startup*DashboardDisplayed
+    // latency worse.
+    const parallelRequests = this.flagService.isEnabled(
+      KnownExperimentId.PARALLEL_DASHBOARD_REQUESTS
+    );
+    if (parallelRequests && queries && queries.length > 1) {
+      const requestPromises = queries.map(query =>
+        this.getChanges(changesPerPage, query, offset, options)
+      );
+      return Promise.all(requestPromises).then(results => {
+        if (results.includes(undefined)) return undefined;
+        return results as ChangeInfo[][];
+      });
+    } else {
+      return this.getChangesForMultipleQueries(
+        changesPerPage,
+        queries,
+        offset,
+        options
+      );
+    }
+  }
+
+  /**
    * For every query fetches the matching changes.
    *
    * If options is undefined then default options (see getListChangesOptionsHex) is
@@ -1271,7 +1336,6 @@
 
     // This list MUST be kept in sync with
     // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
-    // This list MUST be kept in sync with getResponseFormatOptions
     const options = [
       ListChangesOption.ALL_COMMITS,
       ListChangesOption.ALL_REVISIONS,
@@ -1295,35 +1359,6 @@
     return options;
   }
 
-  async getResponseFormatOptions(): Promise<string[]> {
-    const config = await this.getConfig(false);
-
-    // This list MUST be kept in sync with
-    // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
-    // This list MUST be kept in sync with getChangeOptions
-    const options = [
-      'ALL_COMMITS',
-      'ALL_REVISIONS',
-      'CHANGE_ACTIONS',
-      'DETAILED_LABELS',
-      'DETAILED_ACCOUNTS',
-      'DOWNLOAD_COMMANDS',
-      'MESSAGES',
-      'REVIEWER_UPDATES',
-      'SUBMITTABLE',
-      'WEB_LINKS',
-      'SKIP_DIFFSTAT',
-      'SUBMIT_REQUIREMENTS',
-    ];
-    if (this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA)) {
-      options.push('PARENTS');
-    }
-    if (config?.receive?.enable_signed_push) {
-      options.push('PUSH_CERTIFICATES');
-    }
-    return options;
-  }
-
   /**
    * @param optionsHex list changes options in hex
    */
@@ -1589,6 +1624,10 @@
     this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
   }
 
+  invalidateAccountsEmailCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/emails');
+  }
+
   getGroups(filter: string, groupsPerPage: number, offset?: number) {
     const url = this._getGroupsUrl(filter, groupsPerPage, offset);
 
@@ -2037,6 +2076,20 @@
     });
   }
 
+  getSaveReviewChangeOptions(): string[] {
+    const options = [
+      'CHANGE_ACTIONS',
+      'DETAILED_LABELS',
+      'DETAILED_ACCOUNTS',
+      'MESSAGES',
+      'REVIEWER_UPDATES',
+      'SUBMITTABLE',
+      'SKIP_DIFFSTAT',
+      'SUBMIT_REQUIREMENTS',
+    ];
+    return options;
+  }
+
   async saveChangeReview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -2045,7 +2098,7 @@
     fetchDetail?: boolean
   ): Promise<ReviewResult | undefined> {
     if (fetchDetail) {
-      review.response_format_options = await this.getResponseFormatOptions();
+      review.response_format_options = this.getSaveReviewChangeOptions();
     }
     const promises: [Promise<void>, Promise<string>] = [
       this.awaitPendingDiffDrafts(),
@@ -2128,7 +2181,9 @@
         : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
     return promise.then(res => {
-      if (!res.ok) {
+      // A 204 is returned if the file is empty so we have
+      // to return early.
+      if (!res.ok || res.status === 204) {
         return res;
       }
 
@@ -2313,15 +2368,28 @@
 
   async applyFixSuggestion(
     changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    fixReplacementInfos: FixReplacementInfo[]
+    fixPatchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[],
+    targetPatchNum?: PatchSetNum
   ): Promise<Response> {
-    const url = await this._changeBaseURL(changeNum, patchNum);
+    const url = await this._changeBaseURL(
+      changeNum,
+      targetPatchNum ?? fixPatchNum
+    );
+    const body: {
+      fix_replacement_infos: FixReplacementInfo[];
+      original_patchset_for_fix?: PatchSetNum;
+    } = {
+      fix_replacement_infos: fixReplacementInfos,
+    };
+    if (targetPatchNum !== undefined && targetPatchNum !== fixPatchNum) {
+      body.original_patchset_for_fix = fixPatchNum;
+    }
     return this._restApiHelper.fetch({
       fetchOptions: getFetchOptions({
         method: HttpMethod.POST,
         headers: {Accept: 'application/json'},
-        body: {fix_replacement_infos: fixReplacementInfos},
+        body,
       }),
       url: `${url}/fix:apply`,
       anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:apply`,
@@ -2859,17 +2927,15 @@
   }
 
   _fetchB64File(url: string): Promise<Base64File> {
-    return this._restApiHelper
-      .fetch({url: getBaseUrl() + url})
-      .then(response => {
-        if (!response.ok) {
-          return Promise.reject(new Error(response.statusText));
-        }
-        const type = response.headers.get('X-FYI-Content-Type');
-        return response.text().then(text => {
-          return {body: text, type};
-        });
+    return this._restApiHelper.fetch({url}).then(response => {
+      if (!response.ok) {
+        return Promise.reject(new Error(response.statusText));
+      }
+      const type = response.headers.get('X-FYI-Content-Type');
+      return response.text().then(text => {
+        return {body: text, type};
       });
+    });
   }
 
   getB64FileContents(
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index af12a9c..204d626 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import {
   addListenerForTest,
@@ -20,6 +21,7 @@
   createChange,
   createComment,
   createEditInfo,
+  createFixReplacementInfo,
   createParsedChange,
   createServerInfo,
   TEST_PROJECT_NAME,
@@ -1898,4 +1900,69 @@
       anonymizedUrl: '/accounts/self/starred.changes/*',
     });
   });
+  suite('applyFixSuggestion', () => {
+    const fixReplacementInfo = createFixReplacementInfo();
+    let fetchStub: sinon.SinonStub;
+    setup(() => {
+      element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+      fetchStub = sinon
+        .stub(element._restApiHelper, 'fetch')
+        .resolves(new Response(makePrefixedJSON({})));
+    });
+    test('applyFixSuggestion without targetPatchNum', async () => {
+      await element.applyFixSuggestion(
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        [fixReplacementInfo]
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/1/fix:apply'
+      );
+      const body = JSON.parse(fetchStub.lastCall.args[0].fetchOptions.body);
+      assert.isTrue(
+        Object.keys(body).length === 1 &&
+          body.fix_replacement_infos.length === 1
+      );
+      assert.deepEqual(body.fix_replacement_infos[0], fixReplacementInfo);
+    });
+
+    test('applyFixSuggestion with same patchNum and targetPatchNum', async () => {
+      const fixReplacementInfo = createFixReplacementInfo();
+      await element.applyFixSuggestion(
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        [fixReplacementInfo],
+        1 as PatchSetNum
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/1/fix:apply'
+      );
+      const body = JSON.parse(fetchStub.lastCall.args[0].fetchOptions.body);
+      assert.isTrue(Object.keys(body).length === 1);
+      assert.deepEqual(body.fix_replacement_infos[0], fixReplacementInfo);
+    });
+
+    test('applyFixSuggestion with targetPatchNum', async () => {
+      const fixReplacementInfo = createFixReplacementInfo();
+      await element.applyFixSuggestion(
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        [fixReplacementInfo],
+        2 as PatchSetNum
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/2/fix:apply'
+      );
+      const body = JSON.parse(fetchStub.lastCall.args[0].fetchOptions.body);
+      assert.isTrue(Object.keys(body).length === 2);
+      assert.deepEqual(body.fix_replacement_infos[0], fixReplacementInfo);
+      assert.deepEqual(body.original_patchset_for_fix, 1);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 947952c..799cf43 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -10,6 +10,7 @@
   AccountExternalIdInfo,
   AccountId,
   AccountInfo,
+  AccountStateInfo,
   ActionNameToActionInfoMap,
   Base64FileContent,
   BasePatchSetNum,
@@ -114,6 +115,13 @@
   getConfig(noCache?: boolean): Promise<ServerInfo | undefined>;
   getLoggedIn(): Promise<boolean>;
   getPreferences(): Promise<PreferencesInfo | undefined>;
+
+  /**
+   * Fetch the account state of the current user.
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#get-state
+   */
+  getAccountState(): Promise<AccountStateInfo | undefined>;
+
   getVersion(): Promise<string | undefined>;
   getAccount(): Promise<AccountDetailInfo | undefined>;
   getAccountCapabilities(
@@ -493,6 +501,12 @@
     options?: string,
     errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined>;
+  getChangesForDashboard(
+    changesPerPage?: number,
+    query?: string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[][] | undefined>;
   getChangesForMultipleQueries(
     changesPerPage?: number,
     query?: string[],
@@ -542,6 +556,7 @@
   invalidateReposCache(): void;
   invalidateAccountsCache(): void;
   invalidateAccountsDetailCache(): void;
+  invalidateAccountsEmailCache(): void;
   removeFromAttentionSet(
     changeNum: NumericChangeId,
     user: AccountId,
@@ -644,6 +659,10 @@
   deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
   deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
   saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+  saveRepoConfigForReview(
+    repo: RepoName,
+    config: ConfigInput
+  ): Promise<ChangeInfo | undefined>;
 
   getRelatedChanges(
     changeNum: NumericChangeId,
@@ -719,8 +738,9 @@
    */
   applyFixSuggestion(
     changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    fixReplacementInfos: FixReplacementInfo[]
+    fixPatchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[],
+    targetPatchNum?: PatchSetNum
   ): Promise<Response>;
 
   /**
diff --git a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
index e96a2ad..0118e2e 100644
--- a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2019 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
 import {getAppContext} from '../app-context';
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 05927f3..09ba661 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -18,6 +18,7 @@
   PLUGIN_SCREEN = 'plugin-screen',
   REPO = 'repo',
   SEARCH = 'search',
+  SERVER_INFO = 'server-info',
   SETTINGS = 'settings',
 }
 
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
index 041aed2..ee02cfc 100644
--- a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import {assertFails, waitEventLoop} from '../../test/test-utils';
 import {Scheduler} from './scheduler';
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index e13cf19..cfcebc1 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {getAppContext} from './app-context';
 import '../test/common-test-setup';
 import {ServiceWorkerInstaller} from './service-worker-installer';
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 164000a..4421e47 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import '../../test/common-test-setup';
 import {
   COMBO_TIMEOUT_MS,
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.ts b/polygerrit-ui/app/services/storage/gr-storage_test.ts
index 72878f8..6c22005 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_test.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import {NumericChangeId} from '../../api/rest-api';
 import '../../test/common-test-setup';
diff --git a/polygerrit-ui/app/styles/material-icons.css b/polygerrit-ui/app/styles/material-icons.css
index 4c0313c..0cce879 100644
--- a/polygerrit-ui/app/styles/material-icons.css
+++ b/polygerrit-ui/app/styles/material-icons.css
@@ -1,8 +1,8 @@
 /**
- * This file has been produced by downloading this file on Sep 6, 2022:
+ * This file has been produced by downloading this file on June 11, 2024:
  * https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0
- * The corresponding ttf file was downloaded on Sep 6, 2022 from:
- * https://fonts.gstatic.com/s/materialsymbolsoutlined/v51/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0J1Llf.woff2
+ * The corresponding ttf file was downloaded on June 11, 2024 from:
+ * https://fonts.gstatic.com/s/materialsymbolsoutlined/v192/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0.woff2
  */
 @font-face {
   font-family: 'Material Symbols Outlined';
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 7742b1f..a85b5d0 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -417,8 +417,8 @@
     --diff-context-control-background-color: #fff7d4;
     --diff-context-control-border-color: #f6e6a5;
     --diff-context-control-color: var(--default-button-text-color);
-    --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
-    --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+    --diff-highlight-range-color: rgba(255, 220, 0, 0.5);
+    --diff-highlight-range-hover-color: rgba(255, 190, 0, 0.5);
     --diff-selection-background-color: #c7dbf9;
     --diff-tab-indicator-color: var(--deemphasized-text-color);
     --diff-trailing-whitespace-indicator: #ff9ad2;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 77f2498..279dcb0 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -9,6 +9,7 @@
   AccountDetailInfo,
   AccountExternalIdInfo,
   AccountInfo,
+  AccountStateInfo,
   ServerInfo,
   ProjectInfo,
   AccountCapabilityInfo,
@@ -199,6 +200,9 @@
   getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getAccountState(): Promise<AccountStateInfo | undefined> {
+    throw new Error('getAccountState() not implemented by RestApiMock.');
+  },
   getAccountStatus(): Promise<string | undefined> {
     return Promise.resolve('');
   },
@@ -256,6 +260,9 @@
   getChanges() {
     return Promise.resolve([]);
   },
+  getChangesForDashboard() {
+    return Promise.resolve([]);
+  },
   getChangesForMultipleQueries() {
     return Promise.resolve([]);
   },
@@ -442,6 +449,7 @@
   invalidateGroupsCache(): void {},
   invalidateReposCache(): void {},
   invalidateAccountsDetailCache(): void {},
+  invalidateAccountsEmailCache(): void {},
   probePath(): Promise<boolean> {
     return Promise.resolve(true);
   },
@@ -518,6 +526,9 @@
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  saveRepoConfigForReview(): Promise<ChangeInfo | undefined> {
+    throw new Error('saveRepoConfigForReview() not implemented by mock.');
+  },
   saveWatchedProjects(): Promise<ProjectWatchInfo[] | undefined> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index cba3a05..5f40cad 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -98,6 +98,8 @@
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {
   DetailedLabelInfo,
+  FixReplacementInfo,
+  PatchSetNumber,
   QuickLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
@@ -428,6 +430,7 @@
     owner: createAccountWithId(),
     // This is documented as optional, but actually always set.
     reviewers: createReviewers(),
+    current_revision_number: 1 as PatchSetNumber,
     ...partial,
   };
 }
@@ -488,6 +491,7 @@
     all_projects: 'All-Projects',
     all_users: 'All-Users',
     doc_search: false,
+    project_state_predicate_enabled: true,
   };
 }
 
@@ -685,6 +689,27 @@
   });
 }
 
+export function createContextGroupWithDelta() {
+  return new GrDiffGroup({
+    type: GrDiffGroupType.CONTEXT_CONTROL,
+    contextGroups: [
+      new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [
+          new GrDiffLine(GrDiffLineType.REMOVE, 8),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+          new GrDiffLine(GrDiffLineType.REMOVE, 9),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+          new GrDiffLine(GrDiffLineType.REMOVE, 10),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+          new GrDiffLine(GrDiffLineType.REMOVE, 11),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+        ],
+      }),
+    ],
+  });
+}
+
 export function createBlame(): BlameInfo {
   return {
     author: 'test-author',
@@ -710,6 +735,7 @@
     email_strategy: EmailStrategy.ENABLED,
     allow_browser_notifications: true,
     allow_suggest_code_while_commenting: true,
+    allow_autocompleting_comments: true,
   };
 }
 
@@ -1206,3 +1232,11 @@
 export function createQuickLabelInfo(): QuickLabelInfo {
   return {};
 }
+
+export function createFixReplacementInfo(): FixReplacementInfo {
+  return {
+    path: 'test/path',
+    range: createRange(),
+    replacement: 'replacement',
+  };
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 0cf9cd9..ae684bf 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -54,14 +54,13 @@
   ConfigParameterInfoBase,
   ContextLine,
   ContributorAgreementInfo,
-  CustomKey,
-  CustomKeyedValues,
   DetailedLabelInfo,
   DownloadInfo,
   DownloadSchemeInfo,
   EDIT,
   EditPatchSet,
   EmailAddress,
+  EmailInfo,
   FetchInfo,
   FileInfo,
   GerritInfo,
@@ -83,6 +82,7 @@
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
   MaxObjectSizeLimitInfo,
+  MetadataInfo,
   NumericChangeId,
   ParentCommitInfo,
   PARENT,
@@ -165,6 +165,7 @@
   DownloadSchemeInfo,
   EditPatchSet,
   EmailAddress,
+  EmailInfo,
   FileInfo,
   FixId,
   FixSuggestionInfo,
@@ -187,6 +188,7 @@
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
   MaxObjectSizeLimitInfo,
+  MetadataInfo,
   NumericChangeId,
   ParentCommitInfo,
   PatchRange,
@@ -374,6 +376,19 @@
 }
 
 /**
+ * The AccountStateInfo entity contains the superset of all information related
+ * to an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-state-info
+ */
+export interface AccountStateInfo {
+  account: AccountInfo;
+  capabilities?: AccountCapabilityInfo;
+  groups: GroupInfo[];
+  external_ids: AccountExternalIdInfo[];
+  metadata: MetadataInfo[];
+}
+
+/**
  * The GroupAuditEventInfo entity contains information about an auditevent of a group.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-audit-event-info
  */
@@ -464,40 +479,11 @@
   visible_to_all: boolean;
 }
 
-/**
- * The GroupsInput entity contains information about groups that should be
- * included into a group or that should be deleted from a group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
- */
-export interface GroupsInput {
-  _one_group?: string;
-  groups?: string[];
-}
-
-/**
- * The MembersInput entity contains information about accounts that should be
- * added as members to a group or that should be deleted from the group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
- */
-export interface MembersInput {
-  _one_member?: string;
-  members?: string[];
-}
-
 export interface CommitInfoWithRequiredCommit extends CommitInfo {
   commit: CommitId;
 }
 
 /**
- * Standalone Commit Info.
- * Same as CommitInfo, except `commit` is required
- * as it is only optional when used inside of the RevisionInfo.
- */
-export interface StandaloneCommitInfo extends CommitInfo {
-  commit: CommitId;
-}
-
-/**
  * The GpgKeysInput entity contains information for adding/deleting GPG keys.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-keys-input
  */
@@ -507,28 +493,6 @@
 }
 
 /**
- * The CacheInfo entity contains information about a cache.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface CacheInfo {
-  name: string;
-  type: string;
-  entries: EntriesInfo;
-  average_get?: string;
-  hit_ratio: HitRatioInfo;
-}
-
-/**
- * The CacheOperationInput entity contains information about an operation that
- * should be executed on caches.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface CacheOperationInput {
-  operation: string;
-  caches?: string[];
-}
-
-/**
  * The CapabilityInfo entity contains information about a capability.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#capability-info
  */
@@ -540,217 +504,6 @@
 export type CapabilityInfoMap = {[id: string]: CapabilityInfo};
 
 /**
- * The ChangeIndexConfigInfo entity contains information about Gerrit
- * configuration from the index.change section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-index-config-info
- */
-export interface ChangeIndexConfigInfo {
-  index_mergeable?: boolean;
-}
-
-/**
- * The CheckAccountExternalIdsResultInfo entity contains the result of running
- * the account external ID consistency check.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface CheckAccountExternalIdsResultInfo {
-  problems: string;
-}
-
-/**
- * The CheckAccountsResultInfo entity contains the result of running the account
- * consistency check.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface CheckAccountsResultInfo {
-  problems: string;
-}
-
-/**
- * The CheckGroupsResultInfo entity contains the result of running the group
- * consistency check.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface CheckGroupsResultInfo {
-  problems: string;
-}
-
-/**
- * The ConsistencyCheckInfo entity contains the results of running consistency
- * checks.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface ConsistencyCheckInfo {
-  check_accounts_result?: CheckAccountsResultInfo;
-  check_account_external_ids_result?: CheckAccountExternalIdsResultInfo;
-  check_groups_result?: CheckGroupsResultInfo;
-}
-
-/**
- * The ConsistencyCheckInput entity contains information about which consistency
- * checks should be run.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface ConsistencyCheckInput {
-  check_accounts?: string;
-  check_account_external_ids?: string;
-  check_groups?: string;
-}
-
-/**
- * The ConsistencyProblemInfo entity contains information about a consistency
- * problem.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface ConsistencyProblemInfo {
-  status: string;
-  message: string;
-}
-
-/**
- * The entity describes the result of a reload of gerrit.config.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface ConfigUpdateInfo {
-  applied: string;
-  rejected: string;
-}
-
-/**
- * The entity describes an updated config value.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface ConfigUpdateEntryInfo {
-  config_key: string;
-  old_value: string;
-  new_value: string;
-}
-
-/**
- * The EmailConfirmationInput entity contains information for confirming an
- * email address.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface EmailConfirmationInput {
-  token: string;
-}
-
-/**
- * The EntriesInfo entity contains information about the entries in acache.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface EntriesInfo {
-  mem?: string;
-  disk?: string;
-  space?: string;
-}
-
-/**
- * The IndexConfigInfo entity contains information about Gerrit configuration
- * from the index section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#index-config-info
- */
-export interface IndexConfigInfo {
-  change: ChangeIndexConfigInfo;
-}
-
-/**
- * The HitRatioInfo entity contains information about the hit ratio of a cache.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface HitRatioInfo {
-  mem: string;
-  disk?: string;
-}
-
-/**
- * The IndexChangesInput contains a list of numerical changes IDs to index.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface IndexChangesInput {
-  changes: string;
-}
-
-/**
- * The JvmSummaryInfo entity contains information about the JVM.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface JvmSummaryInfo {
-  vm_vendor: string;
-  vm_name: string;
-  vm_version: string;
-  os_name: string;
-  os_version: string;
-  os_arch: string;
-  user: string;
-  host?: string;
-  current_working_directory: string;
-  site: string;
-}
-
-/**
- * The MemSummaryInfo entity contains information about the current memory
- * usage.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface MemSummaryInfo {
-  total: string;
-  used: string;
-  free: string;
-  buffers: string;
-  max: string;
-  open_files?: string;
-}
-
-/**
- * The SummaryInfo entity contains information about the current state of the
- * server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface SummaryInfo {
-  task_summary: TaskSummaryInfo;
-  mem_summary: MemSummaryInfo;
-  thread_summary: ThreadSummaryInfo;
-  jvm_summary?: JvmSummaryInfo;
-}
-
-/**
- * The TaskInfo entity contains information about a task in a background work
- * queue.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface TaskInfo {
-  id: string;
-  state: string;
-  start_time: string;
-  delay: string;
-  command: string;
-  remote_name?: string;
-  project?: string;
-}
-
-/**
- * The TaskSummaryInfo entity contains information about the current tasks.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface TaskSummaryInfo {
-  total?: string;
-  running?: string;
-  ready?: string;
-  sleeping?: string;
-}
-
-/**
- * The ThreadSummaryInfo entity contains information about the current threads.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface ThreadSummaryInfo {
-  cpus: string;
-  threads: string;
-  counts: string;
-}
-
-/**
  * The TopMenuEntryInfo entity contains information about a top menu entry.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-entry-info
  */
@@ -817,6 +570,10 @@
   // Must be set for new drafts created in this session.
   // Use the id() utility function for uniquely identifying drafts.
   client_id?: UrlEncodedCommentId;
+  // Must be set for new drafts created in this session.
+  // Timestamp in milliseconds (Date.now()) of when this draft was created in
+  // this session. Allows stable sorting of new comments on the same range.
+  client_created_ms?: number;
   // Must be set for drafts known to the backend.
   // Use the id() utility function for uniquely identifying drafts.
   id?: UrlEncodedCommentId;
@@ -827,6 +584,7 @@
 
 export interface NewDraftInfo extends DraftInfo {
   client_id: UrlEncodedCommentId;
+  client_created_ms: number;
   id: undefined;
   updated: undefined;
 }
@@ -873,7 +631,7 @@
  */
 export function isNew<T extends Comment>(
   x: T | DraftInfo | undefined
-): boolean {
+): x is NewDraftInfo {
   return !!x && !!(x as DraftInfo).client_id && !(x as DraftInfo).id;
 }
 
@@ -981,6 +739,7 @@
   can_add?: boolean;
   can_add_tags?: boolean;
   config_visible?: boolean;
+  require_change_for_config_update?: boolean;
   groups: RepoAccessGroups;
   config_web_links: WebLinkInfo[];
 }
@@ -1171,15 +930,6 @@
 }
 
 /**
- * The CustomKeyedValuesInput entity contains information about hashtags to add to, and/or remove from, a change
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#custom-keyed-values-input
- */
-export interface CustomKeyedValuesInput {
-  add?: CustomKeyedValues;
-  remove?: CustomKey[];
-}
-
-/**
  * The HashtagsInput entity contains information about hashtags to add to, and/or remove from, a change
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#hashtags-input
  */
@@ -1264,16 +1014,6 @@
 }
 
 /**
- * The EmailInfo entity contains information about an email address of a user
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#email-info
- */
-export interface EmailInfo {
-  email: string;
-  preferred?: boolean;
-  pending_confirmation?: boolean;
-}
-
-/**
  * The CapabilityInfo entity contains information about the global capabilities of a user
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#capability-info
  */
@@ -1339,6 +1079,7 @@
   email_format?: EmailFormat;
   allow_browser_notifications?: boolean;
   allow_suggest_code_while_commenting?: boolean;
+  allow_autocompleting_comments?: boolean;
   diff_page_sidebar?: DiffPageSidebar;
 }
 
@@ -1626,7 +1367,7 @@
  * The RelatedChangesInfo entity contains information about related changes.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-changes-info
  */
-export interface RelatedChangesInfo {
+export declare interface RelatedChangesInfo {
   changes: RelatedChangeAndCommitInfo[];
 }
 
@@ -1634,7 +1375,7 @@
  * The RelatedChangeAndCommitInfo entity contains information about a related change and commit.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-change-and-commit-info
  */
-export interface RelatedChangeAndCommitInfo {
+export declare interface RelatedChangeAndCommitInfo {
   project: RepoName;
   change_id?: ChangeId;
   commit: CommitInfoWithRequiredCommit;
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 2a8c7e5..0d1592e 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -16,6 +16,7 @@
   DiffFileMetaInfo as DiffFileMetaInfoApi,
   DiffInfo as DiffInfoApi,
   DiffIntralineInfo,
+  DiffRangesToFocus,
   DiffResponsiveMode,
   DiffPreferencesInfo as DiffPreferenceInfoApi,
   IgnoreWhitespaceType,
@@ -27,6 +28,7 @@
 export type {
   ChangeType,
   DiffIntralineInfo,
+  DiffRangesToFocus,
   DiffResponsiveMode,
   IgnoreWhitespaceType,
   MarkLength,
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 43e06a4..f48588b 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -41,7 +41,6 @@
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
   LOADING = 'LOADING',
-  RELOADING = 'RELOADING',
   LOADED = 'LOADED',
 }
 
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 0853941..b93acc6 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -140,10 +140,10 @@
 
 export function isDetailedAccount(account?: AccountInfo) {
   // In case ChangeInfo is requested without DetailedAccount option, the
-  // reviewer entry is returned as just {_account_id: 123}
-  // This object should also be treated as not detailed account if they have
-  // an AccountId and no email
-  return !!account?.email && !!account?._account_id;
+  // reviewer entry is returned as just {_account_id: 123}.
+  // At least a name or an email must be set for the account to be treated as
+  // "detailed".
+  return (!!account?.email || !!account?.name) && !!account?._account_id;
 }
 
 /**
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
index 72fa791..b1ee50e 100644
--- a/polygerrit-ui/app/utils/account-util_test.ts
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -263,6 +263,7 @@
   test('isDetailedAccount', () => {
     assert.isFalse(isDetailedAccount({_account_id: 12345 as AccountId}));
     assert.isFalse(isDetailedAccount({email: 'abcd' as EmailAddress}));
+    assert.isFalse(isDetailedAccount({name: 'Kermit'}));
 
     assert.isTrue(
       isDetailedAccount({
@@ -270,6 +271,12 @@
         email: 'abcd' as EmailAddress,
       })
     );
+    assert.isTrue(
+      isDetailedAccount({
+        _account_id: 12345 as AccountId,
+        name: 'Kermit',
+      })
+    );
   });
 
   test('fails gracefully when all is not included', async () => {
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index afc16d3..4383cbd 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import {SinonFakeTimers} from 'sinon';
 import '../test/common-test-setup';
diff --git a/polygerrit-ui/app/utils/autocomplete-cache.ts b/polygerrit-ui/app/utils/autocomplete-cache.ts
new file mode 100644
index 0000000..b880ccd
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+export interface AutocompletionContext {
+  draftContent: string;
+  draftContentLength?: number;
+  commentCompletion: string;
+  commentCompletionLength?: number;
+
+  isFullCommentPrediction?: boolean;
+  draftInSyncWithSuggestionLength?: number;
+  modelVersion?: string;
+  outcome?: number;
+  requestDurationMs?: number;
+
+  commentId?: string;
+  commentNumber?: number;
+  filePath?: string;
+  fileExtension?: string;
+
+  similarCharacters?: number;
+  maxSimilarCharacters?: number;
+  acceptedSuggestionsCount?: number;
+  totalAcceptedCharacters?: number;
+  savedDraftLength?: number;
+
+  hasDraftChanged?: boolean;
+}
+
+/**
+ * Caching for autocompleting text, e.g. comments.
+ *
+ * If the user continues typing text that matches the completion hint, then keep the hint.
+ *
+ * If the user backspaces, then continue using previous hint.
+ */
+export class AutocompleteCache {
+  /**
+   * We are using an ordered list instead of a map here, because we want to evict the oldest
+   * entries, if the capacity is exceeded. And we want to prefer newer entries over older
+   * entries, if both match the criteria for being reused.
+   */
+  private cache: AutocompletionContext[] = [];
+
+  constructor(private readonly capacity = 10) {}
+
+  get(content: string): AutocompletionContext | undefined {
+    if (content === '') return undefined;
+    for (let i = this.cache.length - 1; i >= 0; i--) {
+      const cachedContext = this.cache[i];
+      const completionContent = cachedContext.draftContent;
+      const completionHint = cachedContext.commentCompletion;
+      const completionFull = completionContent + completionHint;
+      if (completionContent.length > content.length) continue;
+      if (!completionFull.startsWith(content)) continue;
+      if (completionFull === content) continue;
+      const hint = completionFull.substring(content.length);
+      return {
+        ...cachedContext,
+        draftContent: content,
+        commentCompletion: hint,
+        draftInSyncWithSuggestionLength:
+          content.length - completionContent.length,
+      };
+    }
+    return undefined;
+  }
+
+  set(context: AutocompletionContext) {
+    const index = this.cache.findIndex(
+      c => c.draftContent === context.draftContent
+    );
+    if (index !== -1) {
+      this.cache.splice(index, 1);
+    } else if (this.cache.length >= this.capacity) {
+      this.cache.shift();
+    }
+    this.cache.push(context);
+  }
+}
diff --git a/polygerrit-ui/app/utils/autocomplete-cache_test.ts b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
new file mode 100644
index 0000000..970436b
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AutocompleteCache} from './autocomplete-cache';
+import {assert} from '@open-wc/testing';
+
+suite('AutocompleteCache', () => {
+  let cache: AutocompleteCache;
+
+  setup(() => {
+    cache = new AutocompleteCache();
+  });
+
+  const cacheSet = (draftContent: string, commentCompletion: string) => {
+    cache.set({draftContent, commentCompletion});
+  };
+
+  const assertCacheEqual = (
+    draftContent: string,
+    expectedCommentCompletion?: string
+  ) => {
+    assert.equal(
+      cache.get(draftContent)?.commentCompletion,
+      expectedCommentCompletion
+    );
+  };
+
+  test('should get and set values', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foo', 'bar');
+  });
+
+  test('should return undefined for empty content string', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('', undefined);
+  });
+
+  test('should return a value, if completion content+hint start with content', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foo', 'bar');
+    assertCacheEqual('foob', 'ar');
+    assertCacheEqual('fooba', 'r');
+    assertCacheEqual('foobar', undefined);
+  });
+
+  test('should not return a value, if content is shorter than completion content', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('f', undefined);
+    assertCacheEqual('fo', undefined);
+  });
+
+  test('should not get values that are not set', () => {
+    assertCacheEqual('foo', undefined);
+  });
+
+  test('should not return an empty completion, if content equals completion content+hint', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foobar', undefined);
+  });
+
+  test('skips over the first entry, but returns the second entry', () => {
+    cacheSet('foobar', 'bang');
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foobar', 'bang');
+  });
+
+  test('replaces entries', () => {
+    cacheSet('foo', 'bar');
+    cacheSet('foo', 'baz');
+    assertCacheEqual('foo', 'baz');
+  });
+
+  test('prefers newer entries, but also returns older entries', () => {
+    cacheSet('foo', 'bar');
+    assertCacheEqual('foob', 'ar');
+    cacheSet('foob', 'arg');
+    assertCacheEqual('foob', 'arg');
+    assertCacheEqual('foo', 'bar');
+  });
+
+  test('capacity', () => {
+    cache = new AutocompleteCache(1);
+    cacheSet('foo', 'bar');
+    cacheSet('boom', 'bang');
+    assertCacheEqual('foo', undefined);
+  });
+});
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 364f372..3cb1407 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -32,6 +32,7 @@
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
+import {specialFilePathCompare} from './path-list-util';
 import {isMergeParent, getParentIndex} from './patch-set-util';
 import {DiffInfo} from '../types/diff';
 import {FormattedReviewerUpdateInfo} from '../types/types';
@@ -79,26 +80,93 @@
 }
 
 export function sortComments<T extends Comment>(comments: T[]): T[] {
-  return comments.slice(0).sort((c1, c2) => {
-    const n1 = isNew(c1);
-    const n2 = isNew(c2);
-    if (n1 !== n2) return n1 ? 1 : -1;
+  return comments.slice(0).sort(compareComments);
+}
 
-    const d1 = isDraft(c1);
-    const d2 = isDraft(c2);
-    if (d1 !== d2) return d1 ? 1 : -1;
-
-    if (c1.updated && c2.updated) {
-      const date1 = parseDate(c1.updated);
-      const date2 = parseDate(c2.updated);
-      const dateDiff = date1.valueOf() - date2.valueOf();
-      if (dateDiff !== 0) return dateDiff;
+/**
+ * Sorts comments in this order by:
+ * - file path
+ * - patchset
+ * - line/range
+ * - created/updated timestamp
+ * - id
+ */
+export function compareComments(c1: Comment, c2: Comment) {
+  const path1 = c1.path ?? '';
+  const path2 = c2.path ?? '';
+  if (path1 !== path2) {
+    // TODO: Why is this logic not part of specialFilePathCompare()?
+    // '/PATCHSET' will not come before '/COMMIT' when sorting
+    // alphabetically so move it to the front explicitly
+    if (path1 === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return -1;
     }
+    if (path2 === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return 1;
+    }
+    return specialFilePathCompare(path1, path2);
+  }
 
-    const id1 = id(c1);
-    const id2 = id(c2);
-    return id1.localeCompare(id2);
-  });
+  const ps1 = typeof c1.patch_set === 'number' ? c1.patch_set : undefined;
+  const ps2 = typeof c2.patch_set === 'number' ? c2.patch_set : undefined;
+  const psComp = compareNumber(ps1, ps2);
+  if (psComp !== 0) return psComp;
+
+  const line1 = c1.line ?? c1?.range?.end_line;
+  const line2 = c2.line ?? c2?.range?.end_line;
+  const lineComp = compareNumber(line1, line2);
+  if (lineComp !== 0) return lineComp;
+
+  const startLine = compareNumber(c1.range?.start_line, c2.range?.start_line);
+  if (startLine !== 0) return startLine;
+  const endCharComp = compareNumber(
+    c1.range?.end_character,
+    c2.range?.end_character
+  );
+  if (endCharComp !== 0) return endCharComp;
+  const startCharComp = compareNumber(
+    c1.range?.start_character,
+    c2.range?.start_character
+  );
+  if (startCharComp !== 0) return startCharComp;
+
+  // At this point we know that the comment is about the exact same location:
+  // Same file, same patchset, same range.
+
+  // Drafts after published comments.
+  if (isDraft(c1) !== isDraft(c2)) return isDraft(c1) ? 1 : -1;
+  const draft = isDraft(c1);
+
+  // For drafts we have to be careful that saving a draft multiple times does
+  // not affect the sorting. So instead of `updated` we are inspecting the
+  // creation time for newly created drafts in this session. Or alternatively
+  // just use the comment id.
+  if (draft) {
+    const created1 = isNew(c1) ? c1.client_created_ms : undefined;
+    const created2 = isNew(c2) ? c2.client_created_ms : undefined;
+    const createdComp = compareNumber(created1, created2);
+    if (createdComp !== 0) return createdComp;
+  } else {
+    const updated1 =
+      c1.updated !== undefined ? parseDate(c1.updated).getTime() : undefined;
+    const updated2 =
+      c2.updated !== undefined ? parseDate(c2.updated).getTime() : undefined;
+    const updatedComp = compareNumber(updated1, updated2);
+    if (updatedComp !== 0) return updatedComp;
+  }
+
+  const id1 = id(c1);
+  const id2 = id(c2);
+  return id1.localeCompare(id2);
+}
+
+export function compareNumber(n1?: number, n2?: number): number {
+  if (n1 === n2) return 0;
+  if (n1 === undefined) return -1;
+  if (n2 === undefined) return 1;
+  if (Number.isNaN(n1)) return -1;
+  if (Number.isNaN(n2)) return 1;
+  return n1 < n2 ? -1 : 1;
 }
 
 export function createNew(
@@ -108,6 +176,7 @@
   const newDraft: NewDraftInfo = {
     savingState: SavingState.OK,
     client_id: uuid() as UrlEncodedCommentId,
+    client_created_ms: Date.now(),
     id: undefined,
     updated: undefined,
   };
@@ -129,7 +198,10 @@
 }
 
 export function createNewReply(
-  replyingTo: CommentInfo,
+  replyingTo: Pick<
+    CommentInfo,
+    'id' | 'path' | 'patch_set' | 'line' | 'range' | 'side' | 'parent'
+  >,
   message: string,
   unresolved: boolean
 ): DraftInfo {
@@ -232,8 +304,8 @@
 }
 
 export function isResolved(thread: CommentThread): boolean {
-  const lastUnresolved = getLastComment(thread)?.unresolved;
-  return !lastUnresolved ?? false;
+  const lastComment = getLastComment(thread);
+  return lastComment !== undefined ? !lastComment.unresolved : true;
 }
 
 export function isDraftThread(thread: CommentThread): boolean {
@@ -262,6 +334,15 @@
   return isRobot(getFirstComment(thread));
 }
 
+export function hasSuggestion(thread: CommentThread): boolean {
+  const firstComment = getFirstComment(thread);
+  if (!firstComment) return false;
+  return (
+    hasUserSuggestion(firstComment) ||
+    firstComment.fix_suggestions?.[0] !== undefined
+  );
+}
+
 export function hasHumanReply(thread: CommentThread): boolean {
   return countComments(thread) > 1 && !isRobot(getLastComment(thread));
 }
@@ -478,12 +559,17 @@
   return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
 }
 
-export function getUserSuggestionFromString(content: string) {
-  const start =
-    content.indexOf(USER_SUGGESTION_START_PATTERN) +
-    USER_SUGGESTION_START_PATTERN.length;
-  const end = content.indexOf('\n```', start);
-  return content.substring(start, end);
+export function getUserSuggestionFromString(
+  content: string,
+  suggestionIndex = 0
+) {
+  const suggestions = content.split(USER_SUGGESTION_START_PATTERN).slice(1);
+  if (suggestions.length === 0) return '';
+
+  const targetIndex = Math.min(suggestionIndex, suggestions.length - 1);
+  const targetSuggestion = suggestions[targetIndex];
+  const end = targetSuggestion.indexOf('\n```');
+  return end !== -1 ? targetSuggestion.substring(0, end) : targetSuggestion;
 }
 
 export function getUserSuggestion(comment: Comment) {
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 713e6df..031a738 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -18,16 +18,16 @@
   getMentionedThreads,
   isNewThread,
   createNew,
+  getUserSuggestionFromString,
 } from './comment-util';
 import {
   createAccountWithEmail,
   createComment,
   createCommentThread,
 } from '../test/test-data-generators';
-import {CommentSide} from '../constants/constants';
+import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {
   Comment,
-  DraftInfo,
   SavingState,
   PARENT,
   RevisionPatchSetNum,
@@ -35,7 +35,6 @@
   UrlEncodedCommentId,
 } from '../types/common';
 import {assert} from '@open-wc/testing';
-import {FILE} from '../api/diff';
 
 suite('comment-util', () => {
   test('isUnresolved', () => {
@@ -144,31 +143,159 @@
   });
 
   test('comments sorting', () => {
+    const updated = '2023-12-24 12:00:00.123000000' as Timestamp;
     const comments: Comment[] = [
       {
-        id: 'new_draft' as UrlEncodedCommentId,
-        message: 'i do not like either of you',
-        savingState: SavingState.OK,
-        updated: '2015-12-20 15:01:20.396000000' as Timestamp,
-      } as DraftInfo,
-      {
-        id: 'sallys_confession' as UrlEncodedCommentId,
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000' as Timestamp,
-        line: 1,
+        id: 'pslevel' as UrlEncodedCommentId,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        updated,
       },
       {
-        id: 'jacks_reply' as UrlEncodedCommentId,
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000' as Timestamp,
+        id: 'commit-message' as UrlEncodedCommentId,
+        path: SpecialFilePath.COMMIT_MESSAGE,
+        updated,
+      },
+      {
+        id: 'path2-id1-updated-earlier' as UrlEncodedCommentId,
+        path: 'path2',
+        updated: '2023-12-23 12:00:00.123000000' as Timestamp,
+      },
+      {
+        id: 'path2-id1' as UrlEncodedCommentId,
+        path: 'path2',
+        updated,
+      },
+      {
+        id: 'path2-id2' as UrlEncodedCommentId,
+        path: 'path2',
+        updated,
+      },
+      {
+        id: 'path2-id1-updated-later' as UrlEncodedCommentId,
+        path: 'path2',
+        updated: '2023-12-24 15:55:00.123000000' as Timestamp,
+      },
+      {
+        id: 'path2-line1' as UrlEncodedCommentId,
+        path: 'path2',
         line: 1,
-        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        updated,
+      },
+      {
+        id: 'path2-line2' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        updated,
+      },
+      {
+        id: 'range-1-0-2-0' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        range: {
+          start_line: 1,
+          start_character: 0,
+          end_line: 2,
+          end_character: 0,
+        },
+        updated,
+      },
+      {
+        id: 'range-1-0-3-0' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        range: {
+          start_line: 1,
+          start_character: 0,
+          end_line: 3,
+          end_character: 0,
+        },
+        updated,
+      },
+      {
+        id: 'range-2-0-3-0' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        range: {
+          start_line: 2,
+          start_character: 0,
+          end_line: 3,
+          end_character: 0,
+        },
+        updated,
+      },
+      {
+        id: 'range-2-0-3-5' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        range: {
+          start_line: 2,
+          start_character: 0,
+          end_line: 3,
+          end_character: 5,
+        },
+        updated,
+      },
+      {
+        id: 'range-2-5-3-5' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        range: {
+          start_line: 2,
+          start_character: 5,
+          end_line: 3,
+          end_character: 5,
+        },
+        updated,
+      },
+      {
+        id: 'path2-line2-ps1' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        patch_set: 1 as RevisionPatchSetNum,
+        updated,
+      },
+      {
+        id: 'path2-line2-ps2' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        patch_set: 2 as RevisionPatchSetNum,
+        updated,
+      },
+      {
+        id: 'path2-line2-ps2-updated-later' as UrlEncodedCommentId,
+        path: 'path2',
+        line: 2,
+        patch_set: 2 as RevisionPatchSetNum,
+        updated,
+      },
+      {
+        client_id: 'new1' as UrlEncodedCommentId,
+        client_created_ms: 1,
+        savingState: SavingState.OK,
+        path: 'path2',
+        line: 2,
+        patch_set: 2 as RevisionPatchSetNum,
+      },
+      {
+        client_id: 'new2' as UrlEncodedCommentId,
+        client_created_ms: 2,
+        savingState: SavingState.OK,
+        path: 'path2',
+        line: 2,
+        patch_set: 2 as RevisionPatchSetNum,
+      },
+      {
+        client_id: 'new2-sort-by-id' as UrlEncodedCommentId,
+        client_created_ms: 2,
+        savingState: SavingState.OK,
+        path: 'path2',
+        line: 2,
+        patch_set: 2 as RevisionPatchSetNum,
       },
     ];
-    const sortedComments = sortComments(comments);
-    assert.equal(sortedComments[0], comments[1]);
-    assert.equal(sortedComments[1], comments[2]);
-    assert.equal(sortedComments[2], comments[0]);
+    const shuffled = [...comments].sort(() => Math.random() - 0.5);
+    const sorted = sortComments(shuffled);
+    assert.sameOrderedMembers(comments, sorted);
   });
 
   suite('createCommentThreads', () => {
@@ -196,6 +323,7 @@
           message: 'i do not like either of you' as UrlEncodedCommentId,
           savingState: SavingState.OK,
           updated: '2015-12-20 15:01:20.396000000' as Timestamp,
+          line: 1,
           patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
         },
@@ -214,7 +342,7 @@
       assert.equal(actualThreads[1].comments.length, 1);
       assert.deepEqual(actualThreads[1].comments[0], comments[2]);
       assert.equal(actualThreads[1].patchNum, 1 as RevisionPatchSetNum);
-      assert.equal(actualThreads[1].line, FILE);
+      assert.equal(actualThreads[1].line, 1);
     });
 
     test('derives patchNum and range', () => {
@@ -406,4 +534,69 @@
       ]);
     });
   });
+
+  suite('getUserSuggestionFromString', () => {
+    const createSuggestionContent = (suggestions: string[]) =>
+      suggestions
+        .map(s => `${USER_SUGGESTION_START_PATTERN}${s}\n\`\`\``)
+        .join('\n');
+
+    test('returns empty string for content without suggestions', () => {
+      const content = 'This is a comment without any suggestions.';
+      assert.equal(getUserSuggestionFromString(content), '');
+    });
+
+    test('returns first suggestion when no index is provided', () => {
+      const content = createSuggestionContent(['First suggestion']);
+      assert.equal(getUserSuggestionFromString(content), 'First suggestion');
+    });
+
+    test('returns correct suggestion for given index', () => {
+      const content = createSuggestionContent([
+        'First suggestion',
+        'Second suggestion',
+        'Third suggestion',
+      ]);
+      assert.equal(
+        getUserSuggestionFromString(content, 1),
+        'Second suggestion'
+      );
+    });
+
+    test('returns last suggestion when index is out of bounds', () => {
+      const content = createSuggestionContent([
+        'First suggestion',
+        'Second suggestion',
+      ]);
+      assert.equal(
+        getUserSuggestionFromString(content, 5),
+        'Second suggestion'
+      );
+    });
+
+    test('handles suggestion without closing backticks', () => {
+      const content = `${USER_SUGGESTION_START_PATTERN}Unclosed suggestion`;
+      assert.equal(getUserSuggestionFromString(content), 'Unclosed suggestion');
+    });
+
+    test('handles multiple suggestions with varying content', () => {
+      const content = createSuggestionContent([
+        'First\nMultiline\nSuggestion',
+        'Second suggestion',
+        'Third suggestion with `backticks`',
+      ]);
+      assert.equal(
+        getUserSuggestionFromString(content, 0),
+        'First\nMultiline\nSuggestion'
+      );
+      assert.equal(
+        getUserSuggestionFromString(content, 1),
+        'Second suggestion'
+      );
+      assert.equal(
+        getUserSuggestionFromString(content, 2),
+        'Third suggestion with `backticks`'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
index 8d16655..45cdc25 100644
--- a/polygerrit-ui/app/utils/date-util_test.ts
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {Timestamp} from '../types/common';
 import '../test/common-test-setup';
 import {
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
index eca528e..3e41e61 100644
--- a/polygerrit-ui/app/utils/deep-util.ts
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -82,7 +82,7 @@
 /**
  * @param obj Object
  */
-export function deepClone(obj?: object) {
-  if (!obj) return undefined;
-  return JSON.parse(JSON.stringify(obj));
+export function deepClone<T>(obj: T): T {
+  if (!obj) throw new Error('undefined object for deepClone');
+  return JSON.parse(JSON.stringify(obj)) as T;
 }
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 03728a0..04e8c19 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -437,13 +437,16 @@
   if (!isElementTarget(rootTarget)) return false;
   const tagName = rootTarget.tagName;
   const type = rootTarget.getAttribute('type');
+  const editable = !!(rootTarget as HTMLElement).isContentEditable;
 
   if (
+    editable ||
     // Suppress shortcuts on <input> and <textarea>, but not on
     // checkboxes, because we want to enable workflows like 'click
     // mark-reviewed and then press ] to go to the next file'.
     (tagName === 'INPUT' && type !== 'checkbox') ||
     tagName === 'TEXTAREA' ||
+    tagName === 'GR-TEXTAREA' ||
     (e.key === 'Enter' &&
       (tagName === 'A' ||
         tagName === 'BUTTON' ||
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index fe185be..7deae3d 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -309,6 +309,14 @@
       });
     });
 
+    test('suppress shortcut event from <div contenteditable>', async () => {
+      const el = document.createElement('div');
+      el.setAttribute('contenteditable', '');
+      await keyEventOn(el, e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
     test('suppress shortcut event from <input>', async () => {
       await keyEventOn(document.createElement('input'), e => {
         assert.isTrue(shouldSuppress(e));
@@ -321,6 +329,12 @@
       });
     });
 
+    test('suppress shortcut event from <gr-textarea>', async () => {
+      await keyEventOn(document.createElement('gr-textarea'), e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
     test('do not suppress shortcut event from checkbox <input>', async () => {
       const inputEl = document.createElement('input');
       inputEl.setAttribute('type', 'checkbox');
diff --git a/polygerrit-ui/app/utils/location-util.ts b/polygerrit-ui/app/utils/location-util.ts
new file mode 100644
index 0000000..d0eac74
--- /dev/null
+++ b/polygerrit-ui/app/utils/location-util.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// This file adds some simple checks to match internal Google rules.
+// Internally at Google it has different a implementation.
+
+import {safeLocation} from 'safevalues/dom';
+
+export function setHref(loc: Location, url: string) {
+  safeLocation.setHref(loc, url);
+}
+
+export function replace(loc: Location, url: string) {
+  safeLocation.replace(loc, url);
+}
+
+export function assign(loc: Location, url: string) {
+  safeLocation.assign(loc, url);
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 7a5cd45..dc7adc1 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -70,7 +70,7 @@
   return patchset as PatchSetNum;
 }
 
-export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
+export function isNumber(psn?: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }
 
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 81dcde1..abc5529 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -115,3 +115,42 @@
     fileName: fileNameSection,
   };
 }
+
+/**
+ * Computes the Levenshtein edit distance between two strings.
+ */
+export function levenshteinDistance(str1: string, str2: string): number {
+  const m = str1.length;
+  const n = str2.length;
+
+  // Create a matrix to store edit distances
+  const dp: number[][] = Array.from({length: m + 1}, () =>
+    Array(n + 1).fill(0)
+  );
+
+  // Initialize first row and column with base cases
+  for (let i = 0; i <= m; i++) {
+    dp[i][0] = i;
+  }
+  for (let j = 0; j <= n; j++) {
+    dp[0][j] = j;
+  }
+
+  // Calculate edit distances for all substrings
+  for (let i = 1; i <= m; i++) {
+    for (let j = 1; j <= n; j++) {
+      if (str1[i - 1] === str2[j - 1]) {
+        dp[i][j] = dp[i - 1][j - 1];
+      } else {
+        dp[i][j] = Math.min(
+          dp[i - 1][j] + 1, // Deletion
+          dp[i][j - 1] + 1, // Insertion
+          dp[i - 1][j - 1] + 1 // Substitution
+        );
+      }
+    }
+  }
+
+  // Return the final edit distance
+  return dp[m][n];
+}
diff --git a/polygerrit-ui/app/utils/submit-requirement-util.ts b/polygerrit-ui/app/utils/submit-requirement-util.ts
index ff99b7f..2eec672 100644
--- a/polygerrit-ui/app/utils/submit-requirement-util.ts
+++ b/polygerrit-ui/app/utils/submit-requirement-util.ts
@@ -36,12 +36,17 @@
       break;
     }
     searchStartIndex = index + match.length;
-    // Include unary minus.
+    // Check for unary minus *before* adding the match
+    let atomIsPassing = isPassing; // Use a local variable
     if (index !== 0 && text[index - 1] === '-') {
       --index;
-      isPassing = !isPassing;
+      atomIsPassing = !isPassing; // Negate only for this occurrence
     }
-    matchedAtoms.push({start: index, end: searchStartIndex, isPassing});
+    matchedAtoms.push({
+      start: index,
+      end: searchStartIndex,
+      isPassing: atomIsPassing,
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/utils/submit-requirement-util_test.ts b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
index a35a121..7982987 100644
--- a/polygerrit-ui/app/utils/submit-requirement-util_test.ts
+++ b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
@@ -113,4 +113,42 @@
       },
     ]);
   });
+
+  test('atomizeExpression b/370742469', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression:
+        '-is:android-cherry-pick_exemptedusers OR is:android-cherry-pick_exemptedusers',
+      passing_atoms: [
+        'is:android-cherry-pick_exemptedusers',
+        'is:android-cherry-pick_exemptedusers',
+        'project:platform/frameworks/support',
+      ],
+      failing_atoms: [
+        'label:Code-Review=MIN',
+        'label:Code-Review=MAX,user=non_uploader',
+        'label:Code-Review=MAX,count>=2',
+        'label:Code-Review=MAX',
+        'label:Exempt=+1',
+        'uploader:1474732',
+        'project:platform/developers/docs',
+      ],
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+        isAtom: true,
+        value: '-is:android-cherry-pick_exemptedusers',
+      },
+      {
+        value: ' OR ',
+        isAtom: false,
+      },
+      {
+        atomStatus: SubmitRequirementExpressionAtomStatus.PASSING,
+        isAtom: true,
+        value: 'is:android-cherry-pick_exemptedusers',
+      },
+    ]);
+  });
 });
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
index 33a19d9..5368911 100644
--- a/polygerrit-ui/app/workers/service-worker-class_test.ts
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import * as sinon from 'sinon';
 import {assert} from '@open-wc/testing';
 import {Timestamp} from '../api/rest-api';
 import '../test/common-test-setup';
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index dcc7479..80ab2ec 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,17 +2,17 @@
 # yarn lockfile v1
 
 
-"@lit-labs/ssr-dom-shim@^1.1.2":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312"
-  integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==
+"@lit-labs/ssr-dom-shim@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.0.tgz#353ce4a76c83fadec272ea5674ede767650762fd"
+  integrity sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==
 
-"@lit/reactive-element@^2.0.0":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.2.tgz#779ae9d265407daaf7737cb892df5ec2a86e22a0"
-  integrity sha512-SVOwLAWUQg3Ji1egtOt1UiFe4zdDpnWHyc5qctSceJ5XIu0Uc76YmGpIjZgx9YJ0XtdW0Jm507sDvjOu+HnB8w==
+"@lit/reactive-element@^2.0.4":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.4.tgz#8f2ed950a848016383894a26180ff06c56ae001b"
+  integrity sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2"
+    "@lit-labs/ssr-dom-shim" "^1.2.0"
 
 "@mapbox/node-pre-gyp@^1.0.0":
   version "1.0.11"
@@ -423,7 +423,7 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.3.1", "@polymer/polymer@^3.5.1":
+"@polymer/polymer@3.5.1", "@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.3.1":
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.1.tgz#4b5234e43b8876441022bcb91313ab3c4a29f0c8"
   integrity sha512-JlAHuy+1qIC6hL1ojEUfIVD58fzTpJAoCxFwV5yr0mYTXV1H8bz5zy0+rC963Cgr9iNXQ4T9ncSjC2fkF9BQfw==
@@ -610,18 +610,29 @@
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
   integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
 
-"highlight.js@^11.5.0 || ^10.4.1", highlight.js@^11.9.0:
+highlight.js@^11.10.0, highlight.js@^11.9.0:
+  version "11.10.0"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92"
+  integrity sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==
+
+"highlight.js@^11.9.0 || ^10.4.1":
   version "11.9.0"
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0"
   integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==
 
-"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
+"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba":
   version "0.0.1"
-  resolved "https://github.com/highlightjs/highlightjs-closure-templates#7922b1e68def8b10199e186bb679600de3ebb711"
+  resolved "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba"
   dependencies:
-    highlight.js "^11.5.0 || ^10.4.1"
+    highlight.js "^11.9.0 || ^10.4.1"
 
-"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text":
+"highlightjs-epp@https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9":
+  version "0.0.1"
+  resolved "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9"
+  dependencies:
+    highlight.js "^11.9.0"
+
+"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e":
   version "1.4.9"
   resolved "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e"
   dependencies:
@@ -658,30 +669,30 @@
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
   integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
 
-lit-element@^4.0.0:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.2.tgz#1a519896d5ab7c7be7a8729f400499e38779c093"
-  integrity sha512-/W6WQZUa5VEXwC7H9tbtDMdSs9aWil3Ou8hU6z2cOKWbsm/tXPAcsoaHVEtrDo0zcOIE5GF6QgU55tlGL2Nihg==
+lit-element@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.1.1.tgz#07905992815076e388cf6f1faffc7d6866c82007"
+  integrity sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2"
-    "@lit/reactive-element" "^2.0.0"
-    lit-html "^3.1.0"
+    "@lit-labs/ssr-dom-shim" "^1.2.0"
+    "@lit/reactive-element" "^2.0.4"
+    lit-html "^3.2.0"
 
-lit-html@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.1.0.tgz#a7b93dd682073f2e2029656f4e9cd91e8034c196"
-  integrity sha512-FwAjq3iNsaO6SOZXEIpeROlJLUlrbyMkn4iuv4f4u1H40Jw8wkeR/OUXZUHUoiYabGk8Y4Y0F/rgq+R4MrOLmA==
+lit-html@^3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.2.1.tgz#8fc49e3531ee5947e4d93e8a5aa642ab1649833b"
+  integrity sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-3.1.0.tgz#76429b85dc1f5169fed499a0f7e89e2e619010c9"
-  integrity sha512-rzo/hmUqX8zmOdamDAeydfjsGXbbdtAFqMhmocnh2j9aDYqbu0fjXygjCa0T99Od9VQ/2itwaGrjZz/ZELVl7w==
+lit@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.2.1.tgz#d6dd15eac20db3a098e81e2c85f70a751ff55592"
+  integrity sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==
   dependencies:
-    "@lit/reactive-element" "^2.0.0"
-    lit-element "^4.0.0"
-    lit-html "^3.1.0"
+    "@lit/reactive-element" "^2.0.4"
+    lit-element "^4.1.0"
+    lit-html "^3.2.0"
 
 lru-cache@^6.0.0:
   version "6.0.0"
@@ -927,10 +938,10 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
 
-web-vitals@^3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.5.1.tgz#af7a9dc60708b81007922ab55a23d963676ba30a"
-  integrity sha512-xQ9lvIpfLxUj0eSmT79ZjRoU5wIRfIr7pNukL7ZE4EcWZSmfZQqOlhuAGfkVa3EFmzPHZhWhXfm2i5ys+THVPg==
+web-vitals@^3.5.2:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.5.2.tgz#5bb58461bbc173c3f00c2ddff8bfe6e680999ca9"
+  integrity sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==
 
 webidl-conversions@^3.0.0:
   version "3.0.1"
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 0465f05..519947e 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -14,7 +14,7 @@
     "@web/test-runner-playwright": "^0.9.0",
     "@web/test-runner-visual-regression": "^0.7.1",
     "accessibility-developer-tools": "^2.12.0",
-    "karma": "^6.4.2",
+    "karma": "^6.4.4",
     "karma-chrome-launcher": "^3.2.0",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index 415571c..eb377d2 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -22,6 +22,9 @@
 
 /** @type {import('@web/test-runner').TestRunnerConfig} */
 const config = {
+  // TODO: https://g-issues.gerritcodereview.com/issues/365565157 - undo the
+  // change once the underlying issue is fixed.
+  concurrency: 1,
   files: [
     "app/**/*_test.{ts,js}",
     "!**/node_modules/**/*",
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 6f53c64..c73413f 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -3885,10 +3885,10 @@
   dependencies:
     minimist "^1.2.3"
 
-karma@^6.4.2:
-  version "6.4.2"
-  resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.2.tgz#a983f874cee6f35990c4b2dcc3d274653714de8e"
-  integrity sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==
+karma@^6.4.4:
+  version "6.4.4"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.4.tgz#dfa5a426cf5a8b53b43cd54ef0d0d09742351492"
+  integrity sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==
   dependencies:
     "@colors/colors" "1.5.0"
     body-parser "^1.19.0"
@@ -3909,7 +3909,7 @@
     qjobs "^1.2.0"
     range-parser "^1.2.1"
     rimraf "^3.0.2"
-    socket.io "^4.4.1"
+    socket.io "^4.7.2"
     source-map "^0.6.1"
     tmp "^0.2.1"
     ua-parser-js "^0.7.30"
@@ -5105,10 +5105,10 @@
     "@socket.io/component-emitter" "~3.1.0"
     debug "~4.3.1"
 
-socket.io@^4.4.1:
-  version "4.7.2"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.2.tgz#22557d76c3f3ca48f82e73d68b7add36a22df002"
-  integrity sha512-bvKVS29/I5fl2FGLNHuXlQaUH/BlzX1IN6S+NKLNZpBsPZIDH+90eQmCs2Railn4YUiww4SzUedJ6+uzwFnKLw==
+socket.io@^4.7.2:
+  version "4.7.5"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.5.tgz#56eb2d976aef9d1445f373a62d781a41c7add8f8"
+  integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==
   dependencies:
     accepts "~1.3.4"
     base64id "~2.0.0"
diff --git a/proto/BUILD b/proto/BUILD
index 7aa761d..624568b 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -23,3 +23,9 @@
     visibility = ["//visibility:public"],
     deps = [":entities_proto"],
 )
+
+cc_proto_library(
+    name = "entities_cc_proto",
+    visibility = ["//visibility:public"],
+    deps = [":entities_proto"],
+)
diff --git a/proto/cache.proto b/proto/cache.proto
index e4273bf..09c99df 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -355,6 +355,7 @@
   bool inactive = 6;
   string status = 7;
   string meta_id = 8;
+  string unique_tag = 9;
 }
 
 // Serialized form of com.google.gerrit.server.account.CachedAccountDetails.Key.
diff --git a/proto/entities.proto b/proto/entities.proto
index 335db62..3a59f09 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -31,7 +31,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.Change.
-// Next ID: 25
+// Next ID: 26
 message Change {
   required Change_Id change_id = 1;
   optional Change_Key change_key = 2;
@@ -50,6 +50,7 @@
   optional bool review_started = 22;
   optional Change_Id revert_of = 23;
   optional PatchSet_Id cherry_pick_of = 24;
+  optional string server_id = 25;
 
   // Deleted fields, should not be reused:
   reserved 3;    // row_version
@@ -62,6 +63,31 @@
   reserved 101;  // note_db_state
 }
 
+// Serialized form of com.google.gerrit.extensions.common.ChangeInput.
+// Next ID: 19
+message ChangeInput {
+  optional string project = 1;
+  optional string branch = 2;
+  optional string subject = 3;
+  optional string topic = 4;
+  optional ChangeStatus status = 5;
+  optional bool is_private = 6;
+  optional bool work_in_progress = 7;
+  optional string base_change = 8;
+  optional string base_commit = 9;
+  optional bool new_branch = 10;
+  map<string, string> validation_options = 11;
+  map<string, string> custom_keyed_values = 12;
+  optional MergeInput merge = 13;
+  optional ApplyPatchInput patch = 14;
+  optional AccountInput author = 15;
+  repeated ListChangesOption response_format_options = 16;
+  optional NotifyHandling notify = 17 [default = ALL];
+  // The key is the string representation of the RecipientType enum.
+  // We use a string here because proto does not allow enum keys in maps.
+  map<string, NotifyInfo> notify_details = 18;
+}
+
 // Serialized form of com.google.gerrit.enities.ChangeMessage.
 // Next ID: 3
 message ChangeMessage_Key {
@@ -81,6 +107,98 @@
   optional Account_Id real_author = 7;
 }
 
+// Serialized form of com.google.gerrit.extensions.client.ChangeStatus.
+// Next ID: 3
+enum ChangeStatus {
+ NEW = 0;
+ MERGED = 1;
+ ABANDONED = 2;
+}
+
+// Serialized form of com.google.gerrit.extensions.common.MergeInput.
+// Next ID: 5
+message MergeInput {
+ optional string source = 1;
+ optional string source_branch = 2;
+ optional string strategy = 3;
+ optional bool allow_conflicts = 4;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.ApplyPatchInput.
+// Next ID: 3
+message ApplyPatchInput {
+ optional string patch = 1;
+ optional bool allow_conflicts = 2;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.accounts.AccountInput.
+// Next ID: 8
+message AccountInput {
+ optional string username = 1;
+ optional string name = 2;
+ optional string display_name = 3;
+ optional string email = 4;
+ optional string ssh_key = 5;
+ optional string http_password = 6;
+ repeated string groups = 7;
+}
+
+// Serialized form of com.google.gerrit.extensions.client.ListChangesOption.
+// Next ID: 28
+enum ListChangesOption {
+  LABELS = 0;
+  CURRENT_REVISION = 1;
+  ALL_REVISIONS = 2;
+  CURRENT_COMMIT = 3;
+  ALL_COMMITS = 4;
+  CURRENT_FILES = 5;
+  ALL_FILES = 6;
+  DETAILED_ACCOUNTS = 7;
+  DETAILED_LABELS = 8;
+  MESSAGES = 9;
+  CURRENT_ACTIONS = 10;
+  REVIEWED = 11;
+  DRAFT_COMMENTS = 12;
+  DOWNLOAD_COMMANDS = 13;
+  WEB_LINKS = 14;
+  CHECK = 15;
+  CHANGE_ACTIONS = 16;
+  COMMIT_FOOTERS = 17;
+  PUSH_CERTIFICATES = 18;
+  REVIEWER_UPDATES = 19;
+  SUBMITTABLE = 20;
+  TRACKING_IDS = 21;
+  SKIP_MERGEABLE = 22;
+  SKIP_DIFFSTAT = 23;
+  SUBMIT_REQUIREMENTS = 24;
+  CUSTOM_KEYED_VALUES = 25;
+  STAR = 26;
+  PARENTS = 27;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.NotifyHandling.
+// Next ID: 4
+enum NotifyHandling {
+  NONE = 0;
+  OWNER = 1;
+  OWNER_REVIEWERS = 2;
+  ALL = 3;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.RecipientType.
+// Next ID: 3
+enum RecipientType {
+  TO = 0;
+  CC = 1;
+  BCC = 2;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.NotifyInfo.
+// Next ID: 2
+message NotifyInfo {
+  repeated string accounts = 1;
+}
+
 // Serialized form of com.google.gerrit.entities.PatchSet.Id.
 // Next ID: 3
 message PatchSet_Id {
@@ -160,7 +278,7 @@
 // Next ID: 2
 message ObjectId {
   // Hex string representation of the ID.
-  optional string name = 1;
+  optional string name = 1 [default="0000000000000000000000000000000000000000"];
 }
 
 // Serialized form of a continuation token used for pagination.
@@ -172,7 +290,7 @@
 // Proto representation of the User preferences classes
 // Next ID: 4
 message UserPreferences {
-  // Next ID: 24
+  // Next ID: 26
   message GeneralPreferencesInfo {
     // Number of changes to show in a screen.
     optional int32 changes_per_page = 1 [default = 25];
@@ -251,6 +369,8 @@
 
     repeated string change_table = 18;
     optional bool allow_browser_notifications = 19 [default = true];
+    optional bool allow_suggest_code_while_commenting = 24 [default = true];
+    optional bool allow_autocompleting_comments = 25 [default = true];
     optional string diff_page_sidebar = 23 [default = "NONE"];
   }
   optional GeneralPreferencesInfo general_preferences_info = 1;
@@ -336,11 +456,11 @@
     optional Side side = 2 [default = REVISION];
     message Range {
       // 1-based
-      optional int32 start_line = 1;
+      optional int32 start_line = 1 [default = 1];
       // 0-based
       optional int32 start_char = 2;
       // 1-based
-      optional int32 end_line = 3;
+      optional int32 end_line = 3 [default = 1];
       // 0-based
       optional int32 end_char = 4;
     }
@@ -349,7 +469,7 @@
     // number is identical to the range's end line.
     optional Range position_range = 3;
     // 1-based
-    optional int32 line_number = 4;
+    optional int32 line_number = 4 [default = 1];
   }
 
   // If not set, the comment is on the patchset level.
@@ -371,4 +491,4 @@
   optional fixed64 written_on_millis = 11;
   // Required.
   optional string server_id = 12;
-}
\ No newline at end of file
+}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index b1f6ade..dc56fae 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -371,6 +371,10 @@
 
 GERRIT_USER=`get_config --get container.user`
 
+if test "$(uname -s)" == "Darwin" ; then
+  JAVA_OPTIONS="$JAVA_OPTIONS -XX:-MaxFDLimit"
+fi
+
 #####################################################
 # Configure sane ulimits for a daemon of our size.
 #####################################################
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 4b621b5..8c1840c 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -29,7 +29,7 @@
   {@param unsatisfiedSubmitRequirements: ?}
   {@param oldSubmitRequirements: ?}
   {@param newSubmitRequirements: ?}
-  {$fromName} has posted comments on this change.
+  {$fromName} has posted comments on this change by {$change.ownerName}.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {if $unsatisfiedSubmitRequirements}
     {\n}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 642ef474..432d088 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -63,6 +63,7 @@
 el = text/x-common-lisp
 elm = text/x-elm
 ejs = application/x-ejs
+epp = application/x-epp
 erb = application/x-erb
 erl = text/x-erlang
 es6 = text/jsx
diff --git a/tools/BUILD b/tools/BUILD
index 71ad096..dc40427 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -4,18 +4,16 @@
     "default_java_toolchain",
 )
 load("@rules_java//java:defs.bzl", "java_package_configuration")
+load("@rules_proto//proto:defs.bzl", "proto_lang_toolchain")
 
 exports_files(["nongoogle.bzl"])
 
-default_java_toolchain(
-    name = "error_prone_warnings_toolchain_java11",
-    configuration = NONPREBUILT_TOOLCHAIN_CONFIGURATION,
-    package_configuration = [
-        ":error_prone",
-    ],
-    source_version = "11",
-    target_version = "11",
-    visibility = ["//visibility:public"],
+proto_lang_toolchain(
+    name = "protoc_java_toolchain",
+    command_line = "--java_out=%s",
+    progress_message = "Generating Java proto_library %{label}",
+    runtime = "@protobuf-java//jar",
+    toolchain_type = "@rules_java//java/proto:toolchain_type",
 )
 
 [default_java_toolchain(
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
index 3c80fc3..e196c10 100644
--- a/tools/bzl/classpath.bzl
+++ b/tools/bzl/classpath.bzl
@@ -1,3 +1,5 @@
+load("@rules_java//java:defs.bzl", "JavaInfo")
+
 def _classpath_collector(ctx):
     all = []
     for d in ctx.attr.deps:
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 5aba90e..131254e 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -14,6 +14,8 @@
 
 # Javadoc rule.
 
+load("@rules_java//java:defs.bzl", "JavaInfo", "java_common")
+
 def _impl(ctx):
     zip_output = ctx.outputs.zip
 
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index d10e113..c7d2545 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -67,12 +67,6 @@
     implementation = _impl,
 )
 
-POST_JDK8_OPTS = [
-    # Enforce JDK 8 compatibility on Java 9, see
-    # https://docs.oracle.com/javase/9/intl/internationalization-enhancements-jdk-9.htm#JSINT-GUID-AF5AECA7-07C1-4E7D-BC10-BC7E73DC6C7F
-    "-Djava.locale.providers=COMPAT",
-]
-
 def junit_tests(name, srcs, **kwargs):
     s_name = name.replace("-", "_") + "TestSuite"
     _gen_suite(
@@ -80,8 +74,8 @@
         srcs = srcs,
         outname = s_name,
     )
-    jvm_flags = kwargs.get("jvm_flags", []) + POST_JDK8_OPTS
-    jvm_flags = jvm_flags + POST_JDK8_OPTS
+    jvm_flags = kwargs.get("jvm_flags", [])
+    jvm_flags = jvm_flags
     java_test(
         name = name,
         test_class = s_name,
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 4792de2..1c3444e 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,7 +14,9 @@
 
 # War packaging.
 
-load("//tools:deps.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_GSON_VERSION", "AUTO_VALUE_VERSION")
+load("@rules_java//java:defs.bzl", "JavaInfo")
+load("//tools:deps.bzl", "AUTO_VALUE_GSON_VERSION")
+load("//tools:nongoogle.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_VERSION")
 
 jar_filetype = [".jar"]
 
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 9e515e5..5bacec8 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,6 +1,10 @@
+"""
+Build rules for plugins.
+"""
+
+load("//:version.bzl", "GERRIT_VERSION")
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//:version.bzl", "GERRIT_VERSION")
 
 IN_TREE_BUILD_MODE = True
 
diff --git a/tools/bzl/plugins.bzl b/tools/bzl/plugins.bzl
index adde59e..9c67c8a 100644
--- a/tools/bzl/plugins.bzl
+++ b/tools/bzl/plugins.bzl
@@ -7,6 +7,7 @@
     "hooks",
     "plugin-manager",
     "replication",
+    "replication:replication-api",
     "reviewnotes",
     "singleusergroup",
     "webhooks",
diff --git a/tools/defs.bzl b/tools/defs.bzl
index ff207b3..3ebc7dc 100644
--- a/tools/defs.bzl
+++ b/tools/defs.bzl
@@ -1,13 +1,25 @@
-load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
+"""
+Bazel definitions for tools.
+"""
+
+load("@bazel_features//:deps.bzl", "bazel_features_deps")
+load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies")
+load("@toolchains_protoc//protoc:repositories.bzl", "rules_protoc_dependencies")
+load("@toolchains_protoc//protoc:toolchain.bzl", "protoc_toolchains")
 
 def gerrit_init():
     """
     Initialize the WORKSPACE for gerrit targets
     """
-    protobuf_deps()
+    rules_protoc_dependencies()
 
-    native.register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
+    rules_proto_dependencies()
 
-    native.register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
+    bazel_features_deps()
 
-    native.register_toolchains("//tools:error_prone_warnings_toolchain_java21_definition")
+    protoc_toolchains(
+        name = "toolchains_protoc_hub",
+        version = "v25.3",
+    )
+
+    native.register_toolchains("//tools:all")
diff --git a/tools/deps.bzl b/tools/deps.bzl
index d056483..2de37d5 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -10,10 +10,7 @@
 GREENMAIL_VERS = "1.5.5"
 MAIL_VERS = "1.6.0"
 MIME4J_VERS = "0.8.1"
-OW2_VERS = "9.2"
-AUTO_COMMON_VERSION = "1.2.1"
-AUTO_FACTORY_VERSION = "1.0.1"
-AUTO_VALUE_VERSION = "1.10.4"
+OW2_VERS = "9.7"
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
@@ -21,7 +18,7 @@
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
-BC_VERS = "1.72"
+BC_VERS = "1.74"
 HTTPCOMP_VERS = "4.5.14"
 JETTY_VERS = "9.4.53.v20231009"
 BYTE_BUDDY_VERSION = "1.14.9"
@@ -90,12 +87,6 @@
     )
 
     maven_jar(
-        name = "gson",
-        artifact = "com.google.code.gson:gson:2.9.0",
-        sha1 = "8a1167e089096758b49f9b34066ef98b2f4b37aa",
-    )
-
-    maven_jar(
         name = "caffeine",
         artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
         sha1 = "0a17ed335e0ce2d337750772c0709b79af35a842",
@@ -255,61 +246,31 @@
     maven_jar(
         name = "ow2-asm",
         artifact = "org.ow2.asm:asm:" + OW2_VERS,
-        sha1 = "81a03f76019c67362299c40e0ba13405f5467bff",
+        sha1 = "073d7b3086e14beb604ced229c302feff6449723",
     )
 
     maven_jar(
         name = "ow2-asm-analysis",
         artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-        sha1 = "7487dd756daf96cab9986e44b9d7bcb796a61c10",
+        sha1 = "e4a258b7eb96107106c0599f0061cfc1832fe07a",
     )
 
     maven_jar(
         name = "ow2-asm-commons",
         artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-        sha1 = "f4d7f0fc9054386f2893b602454d48e07d4fbead",
+        sha1 = "e86dda4696d3c185fcc95d8d311904e7ce38a53f",
     )
 
     maven_jar(
         name = "ow2-asm-tree",
         artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-        sha1 = "d96c99a30f5e1a19b0e609dbb19a44d8518ac01e",
+        sha1 = "e446a17b175bfb733b87c5c2560ccb4e57d69f1a",
     )
 
     maven_jar(
         name = "ow2-asm-util",
         artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-        sha1 = "fbc178fc5ba3dab50fd7e8a5317b8b647c8e8946",
-    )
-
-    maven_jar(
-        name = "auto-common",
-        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
-        sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
-    )
-
-    maven_jar(
-        name = "auto-factory",
-        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
-        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
-    )
-
-    maven_jar(
-        name = "auto-service-annotations",
-        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
-        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
-    )
-
-    maven_jar(
-        name = "auto-value",
-        artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-        sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
-    )
-
-    maven_jar(
-        name = "auto-value-annotations",
-        artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-        sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
+        sha1 = "c0655519f24d92af2202cb681cd7c1569df6ead6",
     )
 
     maven_jar(
@@ -423,25 +384,25 @@
     maven_jar(
         name = "bcprov",
         artifact = "org.bouncycastle:bcprov-jdk18on:" + BC_VERS,
-        sha1 = "d8dc62c28a3497d29c93fee3e71c00b27dff41b4",
+        sha1 = "8753dedf57165efdb1a7a69a90fe49a77353efb9",
     )
 
     maven_jar(
         name = "bcpg",
         artifact = "org.bouncycastle:bcpg-jdk18on:" + BC_VERS,
-        sha1 = "1a36a1740d07869161f6f0d01fae8d72dd1d8320",
+        sha1 = "08af7527e1e13b4fcfc55ff81b99becd12f319c7",
     )
 
     maven_jar(
         name = "bcpkix",
         artifact = "org.bouncycastle:bcpkix-jdk18on:" + BC_VERS,
-        sha1 = "bb3fdb5162ccd5085e8d7e57fada4d8eaa571f5a",
+        sha1 = "a197fb87f0697c1925e7248865ee84516fdb6d9c",
     )
 
     maven_jar(
         name = "bcutil",
         artifact = "org.bouncycastle:bcutil-jdk18on:" + BC_VERS,
-        sha1 = "41f19a69ada3b06fa48781120d8bebe1ba955c77",
+        sha1 = "929723bc9ef128aadba955929f701393bc6a153b",
     )
 
     maven_jar(
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index c1d8095..2c70939 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -36,7 +36,7 @@
     name = "main_classpath_collect",
     testonly = True,
     deps = LIBS + PGMLIBS + DEPS + TEST_DEPS + TEST_DEPS_GENERATED +
-           ["//plugins/%s:%s__plugin" % (n, n) for n in CORE_PLUGINS + CUSTOM_PLUGINS] +
+           ["//plugins/%s__plugin" % (n if ":" in n else "%s:%s" % (n, n)) for n in CORE_PLUGINS + CUSTOM_PLUGINS] +
            ["//plugins/%s:%s__plugin_test_deps" % (n, n) for n in CUSTOM_PLUGINS_TEST_DEPS],
 )
 
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index a3f4d8f..1c2443e 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -49,7 +49,7 @@
 opts.add_argument('-b', '--batch', action='store_true',
                   dest='batch', help='Bazel batch option')
 opts.add_argument('-j', '--java', action='store',
-                  dest='java', help='Post Java 11')
+                  dest='java', help='Post Java 17')
 opts.add_argument('--bazel',
                   help=('name of the bazel executable. Defaults to using'
                         ' bazelisk if found, or bazel if bazelisk is not'
@@ -97,6 +97,7 @@
         cmd.append(arg)
     if custom_java:
         cmd.append('--config=java%s' % custom_java)
+    cmd.append('--remote_download_outputs=all')
     return cmd
 
 
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index f5fe24c..366f22c 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.12.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index abe1793..59f9016 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.12.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 3240b14..c549677 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.12.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 52e98a04..9b26260 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.12.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index deee0d4..39697be 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -7,6 +7,12 @@
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
 
+AUTO_COMMON_VERSION = "1.2.2"
+
+AUTO_FACTORY_VERSION = "1.0.1"
+
+AUTO_VALUE_VERSION = "1.10.4"
+
 GUAVA_VERSION = "33.0.0-jre"
 
 GUAVA_BIN_SHA1 = "161ba27964a62f241533807a46b8711b13c1d94b"
@@ -18,27 +24,43 @@
 def archive_dependencies():
     return [
         {
-            "name": "com_google_protobuf",
-            "sha256": "9bd87b8280ef720d3240514f884e56a712f2218f0d693b48050c836028940a42",
-            "strip_prefix": "protobuf-25.1",
-            "urls": [
-                "https://github.com/protocolbuffers/protobuf/archive/v25.1.tar.gz",
-            ],
-        },
-        {
             "name": "platforms",
             "urls": [
-                "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
-                "https://github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
+                "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz",
+                "https://github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz",
             ],
-            "sha256": "3a561c99e7bdbe9173aa653fd579fe849f1d8d67395780ab4770b1f381431d51",
+            "sha256": "218efe8ee736d26a3572663b374a253c012b716d8af0c07e842e82f238a0a7ee",
+        },
+        {
+            "name": "bazel_features",
+            "strip_prefix": "bazel_features-1.11.0",
+            "urls": [
+                "https://github.com/bazel-contrib/bazel_features/releases/download/v1.11.0/bazel_features-v1.11.0.tar.gz",
+            ],
+            "sha256": "2cd9e57d4c38675d321731d65c15258f3a66438ad531ae09cb8bb14217dc8572",
         },
         {
             "name": "rules_java",
             "urls": [
-                "https://github.com/bazelbuild/rules_java/releases/download/7.3.1/rules_java-7.3.1.tar.gz",
+                "https://github.com/bazelbuild/rules_java/releases/download/7.6.1/rules_java-7.6.1.tar.gz",
             ],
-            "sha256": "4018e97c93f97680f1650ffd2a7530245b864ac543fd24fae8c02ba447cb2864",
+            "sha256": "f8ae9ed3887df02f40de9f4f7ac3873e6dd7a471f9cddf63952538b94b59aeb3",
+        },
+        {
+            "name": "rules_proto",
+            "strip_prefix": "rules_proto-6.0.0",
+            "urls": [
+                "https://github.com/bazelbuild/rules_proto/releases/download/6.0.0/rules_proto-6.0.0.tar.gz",
+            ],
+            "sha256": "303e86e722a520f6f326a50b41cfc16b98fe6d1955ce46642a5b7a67c11c0f5d",
+        },
+        {
+            "name": "toolchains_protoc",
+            "strip_prefix": "toolchains_protoc-0.3.0",
+            "urls": [
+                "https://github.com/aspect-build/toolchains_protoc/releases/download/v0.3.0/toolchains_protoc-v0.3.0.tar.gz",
+            ],
+            "sha256": "117af61ee2f1b9b014dcac7c9146f374875551abb8a30e51d1b3c5946d25b142",
         },
         {
             "name": "ubuntu2204_jdk17",
@@ -115,18 +137,18 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.12.0"
+    SSHD_VERS = "2.14.0"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "32b8de1cbb722ba75bdf9898e0c41d42af00ce57",
+        sha1 = "6ef66228a088f8ac1383b2ff28f3102f80ebc01a",
     )
 
     maven_jar(
         name = "sshd-sftp",
         artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
-        sha1 = "0f96f00a07b186ea62838a6a4122e8f4cad44df6",
+        sha1 = "c070ac920e72023ae9ab0a3f3a866bece284b470",
     )
 
     maven_jar(
@@ -144,7 +166,7 @@
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "8b202f7d4c0d7b714fd0c93a1352af52aa031149",
+        sha1 = "05e1293af53a196ac3c5a4b01dd88985e8672e9e",
     )
 
     maven_jar(
@@ -182,6 +204,36 @@
     # no concern about version skew.
 
     maven_jar(
+        name = "auto-common",
+        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+        sha1 = "9d38f10e22411681cf1d1ee3727e002af19f2c9e",
+    )
+
+    maven_jar(
+        name = "auto-factory",
+        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+    )
+
+    maven_jar(
+        name = "auto-service-annotations",
+        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+    )
+
+    maven_jar(
+        name = "auto-value",
+        artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+        sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
+    )
+
+    maven_jar(
+        name = "auto-value-annotations",
+        artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+        sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
+    )
+
+    maven_jar(
         name = "error-prone-annotations",
         artifact = "com.google.errorprone:error_prone_annotations:2.22.0",
         sha1 = "bfb9e4281a4cea34f0ec85b3acd47621cfab35b4",
@@ -252,6 +304,18 @@
         sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e",
     )
 
+    maven_jar(
+        name = "gson",
+        artifact = "com.google.code.gson:gson:2.10.1",
+        sha1 = "b3add478d4382b78ea20b1671390a858002feb6c",
+    )
+
+    maven_jar(
+        name = "protobuf-java",
+        artifact = "com.google.protobuf:protobuf-java:3.25.3",
+        sha1 = "d3200261955f3298e0d85c9892201e70492ce8eb",
+    )
+
     # Test-only dependencies below.
     maven_jar(
         name = "cglib-3_2",
@@ -271,30 +335,30 @@
         sha1 = "48462eb319817c90c27d377341684b6b81372e08",
     )
 
-    TRUTH_VERS = "1.4.0"
+    TRUTH_VERS = "1.4.2"
 
     maven_jar(
         name = "truth",
         artifact = "com.google.truth:truth:" + TRUTH_VERS,
-        sha1 = "2a9475ed8cf2081b859fda8f9c860d5c449cd9ed",
+        sha1 = "2322d861290bd84f84cbb178e43539725a4588fd",
     )
 
     maven_jar(
         name = "truth-java8-extension",
         artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-        sha1 = "807a8226b465e5df74cc0591762f729195c3f144",
+        sha1 = "bfa44a01e1bb5a1df50bc9c678d6588b4d9eb73a",
     )
 
     maven_jar(
         name = "truth-liteproto-extension",
         artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-        sha1 = "849821a1b377119104df95f87cb3e5b9d674c8a8",
+        sha1 = "062a2716b3b0ba9d8e72c913dad43a8139b12202",
     )
 
     maven_jar(
         name = "truth-proto-extension",
         artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-        sha1 = "20a6fbb4276320e4df5fb42fe327bc9fb0f56dd1",
+        sha1 = "53cfc94dfa435c5dcd6f8b6844b82b423ea0a5af",
     )
 
     LUCENE_VERS = "9.8.0"
diff --git a/tools/rules_nodejs-5.8.4-node_versions.bzl.patch b/tools/rules_nodejs-5.8.4-node_versions.bzl.patch
deleted file mode 100644
index 7df62d6..0000000
--- a/tools/rules_nodejs-5.8.4-node_versions.bzl.patch
+++ /dev/null
@@ -1,17 +0,0 @@
-diff --git a/nodejs/private/node_versions.bzl b/nodejs/private/node_versions.bzl
-index bbb45b26..8758b3cc 100644
---- a/nodejs/private/node_versions.bzl
-+++ b/nodejs/private/node_versions.bzl
-@@ -2311,4 +2311,12 @@ NODE_VERSIONS = {
-     "18.17.0-linux_s390x": ("node-v18.17.0-linux-s390x.tar.xz", "node-v18.17.0-linux-s390x", "876ca54c246d24e346d0c740fbb72c9fb7353369127f20492bc923ee6d0121db"),
-     "18.17.0-linux_amd64": ("node-v18.17.0-linux-x64.tar.xz", "node-v18.17.0-linux-x64", "f36facda28c4d5ce76b3a1b4344e688d29d9254943a47f2f1909b1a10acb1959"),
-     "18.17.0-windows_amd64": ("node-v18.17.0-win-x64.zip", "node-v18.17.0-win-x64", "06e30b4e70b18d794651ef132c39080e5eaaa1187f938721d57edae2824f4e96"),
-+    # 20.9.0
-+    "20.9.0-darwin_arm64": ("node-v20.9.0-darwin-arm64.tar.gz", "node-v20.9.0-darwin-arm64", "31d2d46ae8d8a3982f54e2ff1e60c2e4a8e80bf78a3e8b46dcaac95ac5d7ce6a"),
-+    "20.9.0-darwin_amd64": ("node-v20.9.0-darwin-x64.tar.gz", "node-v20.9.0-darwin-x64", "fc5b73f2a78c17bbe926cdb1447d652f9f094c79582f1be6471b4b38a2e1ccc8"),
-+    "20.9.0-linux_arm64": ("node-v20.9.0-linux-arm64.tar.xz", "node-v20.9.0-linux-arm64", "ced3ecece4b7c3a664bca3d9e34a0e3b9a31078525283a6fdb7ea2de8ca5683b"),
-+    "20.9.0-linux_ppc64le": ("node-v20.9.0-linux-ppc64le.tar.xz", "node-v20.9.0-linux-ppc64le", "3c6cea5d614cfbb95d92de43fbc2f8ecd66e431502fe5efc4f3c02637897bd45"),
-+    "20.9.0-linux_s390x": ("node-v20.9.0-linux-s390x.tar.xz", "node-v20.9.0-linux-s390x", "af1f4e63756ff685d452166c4d5ba93a308e816ee7c46015b5e086163d9f011b"),
-+    "20.9.0-linux_amd64": ("node-v20.9.0-linux-x64.tar.xz", "node-v20.9.0-linux-x64", "9033989810bf86220ae46b1381bdcdc6c83a0294869ba2ad39e1061f1e69217a"),
-+    "20.9.0-windows_amd64": ("node-v20.9.0-win-x64.zip", "node-v20.9.0-win-x64", "70d87dad2378c63216ff83d5a754c61d2886fc39d32ce0d2ea6de763a22d3780"),
- }
diff --git a/tools/run_gjf.sh b/tools/run_gjf.sh
new file mode 100755
index 0000000..c6eea0f
--- /dev/null
+++ b/tools/run_gjf.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+#
+# Copyright (C) 2024 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu
+
+GJF_VERSION=$(grep -o "^VERSION=.*$" tools/setup_gjf.sh | grep -o "[0-9][0-9]*\.[0-9][0-9]*")
+GJF=$(find 'tools/format' -regex '.*/google-java-format-[0-9][0-9]*\.[0-9][0-9]*')
+if [ ! -f "$GJF" ]; then
+  ./setup_gjf.sh
+  GJF=$(find 'tools/format' -regex '.*/google-java-format-[0-9][0-9]*\.[0-9][0-9]*')
+fi
+echo 'Running google-java-format check...'
+git show --diff-filter=AM --name-only --pretty="" HEAD | grep java$ | xargs $GJF -r
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
index 119f9af..0a02837 100755
--- a/tools/setup_gjf.sh
+++ b/tools/setup_gjf.sh
@@ -19,18 +19,15 @@
 # Keep this version in sync with dev-contributing.txt.
 VERSION=${1:-1.7}
 
+
 case "$VERSION" in
-1.3)
-    SHA1="a73cfe6f9af01bd6ff150c0b50c9d620400f784c"
-    ;;
-1.5)
-    SHA1="b1f79e4d39a3c501f07c0ce7e8b03ac6964ed1f1"
-    ;;
-1.6)
-    SHA1="02b3e84e52d2473e2c4868189709905a51647d03"
-    ;;
 1.7)
     SHA1="b6d34a51e579b08db7c624505bdf9af4397f1702"
+    TAG_PREFIX=google-java-format-
+    ;;
+1.22.0)
+    SHA1="693d8fd04656886a2287cfe1d7a118c4697c3a57"
+    TAG_PREFIX=v
     ;;
 *)
     echo "unknown google-java-format version: $VERSION"
@@ -48,7 +45,7 @@
 mkdir -p "$dir"
 
 name="google-java-format-$VERSION-all-deps.jar"
-url="https://github.com/google/google-java-format/releases/download/google-java-format-$VERSION/$name"
+url="https://github.com/google/google-java-format/releases/download/$TAG_PREFIX$VERSION/$name"
 "$root/tools/download_file.py" -o "$dir/$name" -u "$url" -v "$SHA1"
 
 launcher="$dir/google-java-format-$VERSION"
diff --git a/tools/util.py b/tools/util.py
index 947e2c0..aed9e91 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -16,8 +16,10 @@
 
 REPO_ROOTS = {
   'ECLIPSE': 'https://repo.eclipse.org/content/groups/releases',
+  'ECLIPSE_EGIT': 'https://repo.eclipse.org/content/repositories/egit-releases',
   'GERRIT': 'https://gerrit-maven.storage.googleapis.com',
   'GERRIT_API': 'https://gerrit-api.commondatastorage.googleapis.com/release',
+  'JENKINS': 'https://repo.jenkins-ci.org/artifactory/public',
   'MAVEN_CENTRAL': 'https://repo1.maven.org/maven2',
   'MAVEN_LOCAL': 'file://' + path.expanduser('~/.m2/repository'),
   'MAVEN_SNAPSHOT': 'https://oss.sonatype.org/content/repositories/snapshots',
diff --git a/version.bzl b/version.bzl
index d55ddba..8d942e01 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.9.2-SNAPSHOT"
+GERRIT_VERSION = "3.12.0-SNAPSHOT"
diff --git a/web-dev-server.config.mjs b/web-dev-server.config.mjs
index 2a7dca4..53a240e 100644
--- a/web-dev-server.config.mjs
+++ b/web-dev-server.config.mjs
@@ -1,5 +1,7 @@
 import { esbuildPlugin } from "@web/dev-server-esbuild";
 import cors from "@koa/cors";
+import path from 'node:path';
+import fs from 'node:fs';
 
 /** @type {import('@web/dev-server').DevServerConfig} */
 export default {
@@ -18,6 +20,20 @@
     // (ex: gerrit-review.googlesource.com), which happens during local
     // development with Gerrit FE Helper extension.
     cors({ origin: "*" }),
+    // Map some static assets.
+    // When creating the bundle, the files are moved by polygerrit_bundle() in
+    // polygerrit-ui/app/rules.bzl
+    async (context, next) => {
+
+      if ( context.url.includes("/bower_components/webcomponentsjs/webcomponents-lite.js") ) {
+        context.response.redirect("/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js");
+
+      } else if ( context.url.startsWith( "/fonts/" ) ) {
+        const fontFile = path.join( "lib/fonts", path.basename(context.url) );
+        context.body = fs.createReadStream( fontFile );
+      }
+      await next();
+    },
     // The issue solved here is that our production index.html does not load
     // 'gr-app.js' as an ESM module due to our build process, but in development
     // all our source code is written as ESM modules. When using the Gerrit FE
@@ -40,6 +56,7 @@
       await next();
 
       if (!isGrAppMjs && context.url.includes("gr-app.js")) {
+        context.set('Content-Type', 'application/javascript; charset=utf-8');
         context.body = "import('./gr-app.mjs')";
       }
     },
diff --git a/yarn.lock b/yarn.lock
index 6d62a5e..8a5d986 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10,11 +10,6 @@
     lodash.assignwith "^4.2.0"
     typical "^7.1.1"
 
-"@aashutoshrathi/word-wrap@^1.2.3":
-  version "1.2.6"
-  resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf"
-  integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
-
 "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.11":
   version "7.23.5"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244"
@@ -202,17 +197,22 @@
   integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==
 
 "@eslint-community/eslint-utils@^4.2.0":
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
-  integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56"
+  integrity sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==
   dependencies:
-    eslint-visitor-keys "^3.3.0"
+    eslint-visitor-keys "^3.4.3"
 
-"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1":
+"@eslint-community/regexpp@^4.4.0":
   version "4.10.0"
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63"
   integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==
 
+"@eslint-community/regexpp@^4.6.1":
+  version "4.12.1"
+  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
+  integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
+
 "@eslint/eslintrc@^2.1.4":
   version "2.1.4"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad"
@@ -228,18 +228,18 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@eslint/js@8.56.0":
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b"
-  integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==
+"@eslint/js@8.57.1":
+  version "8.57.1"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
+  integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
 
-"@humanwhocodes/config-array@^0.11.13":
-  version "0.11.13"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
-  integrity sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==
+"@humanwhocodes/config-array@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748"
+  integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==
   dependencies:
-    "@humanwhocodes/object-schema" "^2.0.1"
-    debug "^4.1.1"
+    "@humanwhocodes/object-schema" "^2.0.3"
+    debug "^4.3.1"
     minimatch "^3.0.5"
 
 "@humanwhocodes/module-importer@^1.0.1":
@@ -247,15 +247,15 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
   integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
 
-"@humanwhocodes/object-schema@^2.0.1":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz#e5211452df060fa8522b55c7b3c0c4d1981cb044"
-  integrity sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==
+"@humanwhocodes/object-schema@^2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
+  integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
 
-"@koa/cors@^3.4.3":
-  version "3.4.3"
-  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.4.3.tgz#d669ee6e8d6e4f0ec4a7a7b0a17e7a3ed3752ebb"
-  integrity sha512-WPXQUaAeAMVaLTEFpoq3T2O1C+FstkjJnDQqy95Ck1UdILajsRhu6mhJ8H2f4NFPRBoCNN+qywTJfq/gGki5mw==
+"@koa/cors@^5.0.0":
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-5.0.0.tgz#0029b5f057fa0d0ae0e37dd2c89ece315a0daffd"
+  integrity sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==
   dependencies:
     vary "^1.1.2"
 
@@ -372,6 +372,11 @@
     estree-walker "^1.0.1"
     picomatch "^2.2.2"
 
+"@rtsao/scc@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
+  integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
+
 "@types/accepts@*":
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.7.tgz#3b98b1889d2b2386604c2bbbe62e4fb51e95b265"
@@ -763,9 +768,9 @@
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
 acorn@^8.9.0:
-  version "8.11.3"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
-  integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
+  version "8.14.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0"
+  integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==
 
 ajv@^6.12.4:
   version "6.12.6"
@@ -846,23 +851,24 @@
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-6.2.2.tgz#f567d99e9af88a6d3d2f9dfcc21db6f9ba9fd157"
   integrity sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==
 
-array-buffer-byte-length@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz#fabe8bc193fea865f317fe7807085ee0dee5aead"
-  integrity sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==
+array-buffer-byte-length@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f"
+  integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==
   dependencies:
-    call-bind "^1.0.2"
-    is-array-buffer "^3.0.1"
+    call-bind "^1.0.5"
+    is-array-buffer "^3.0.4"
 
-array-includes@^3.1.7:
-  version "3.1.7"
-  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.7.tgz#8cd2e01b26f7a3086cbc87271593fe921c62abda"
-  integrity sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==
+array-includes@^3.1.8:
+  version "3.1.8"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d"
+  integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
-    get-intrinsic "^1.2.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-abstract "^1.23.2"
+    es-object-atoms "^1.0.0"
+    get-intrinsic "^1.2.4"
     is-string "^1.0.7"
 
 array-union@^2.1.0:
@@ -875,16 +881,17 @@
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
   integrity sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==
 
-array.prototype.findlastindex@^1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz#b37598438f97b579166940814e2c0493a4f50207"
-  integrity sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==
+array.prototype.findlastindex@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz#8c35a755c72908719453f87145ca011e39334d0d"
+  integrity sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
-    es-shim-unscopables "^1.0.0"
-    get-intrinsic "^1.2.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-abstract "^1.23.2"
+    es-errors "^1.3.0"
+    es-object-atoms "^1.0.0"
+    es-shim-unscopables "^1.0.2"
 
 array.prototype.flat@^1.3.2:
   version "1.3.2"
@@ -906,17 +913,18 @@
     es-abstract "^1.22.1"
     es-shim-unscopables "^1.0.0"
 
-arraybuffer.prototype.slice@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz#98bd561953e3e74bb34938e77647179dfe6e9f12"
-  integrity sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==
+arraybuffer.prototype.slice@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6"
+  integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==
   dependencies:
-    array-buffer-byte-length "^1.0.0"
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
-    get-intrinsic "^1.2.1"
-    is-array-buffer "^3.0.2"
+    array-buffer-byte-length "^1.0.1"
+    call-bind "^1.0.5"
+    define-properties "^1.2.1"
+    es-abstract "^1.22.3"
+    es-errors "^1.2.1"
+    get-intrinsic "^1.2.3"
+    is-array-buffer "^3.0.4"
     is-shared-array-buffer "^1.0.2"
 
 arrify@^1.0.1:
@@ -941,10 +949,12 @@
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
-available-typed-arrays@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
-  integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
+available-typed-arrays@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
+  integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==
+  dependencies:
+    possible-typed-array-names "^1.0.0"
 
 balanced-match@^1.0.0:
   version "1.0.2"
@@ -1033,14 +1043,16 @@
     mime-types "^2.1.18"
     ylru "^1.2.0"
 
-call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513"
-  integrity sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==
+call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
+  integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==
   dependencies:
+    es-define-property "^1.0.0"
+    es-errors "^1.3.0"
     function-bind "^1.1.2"
-    get-intrinsic "^1.2.1"
-    set-function-length "^1.1.1"
+    get-intrinsic "^1.2.4"
+    set-function-length "^1.2.1"
 
 call-me-maybe@^1.0.1:
   version "1.0.2"
@@ -1273,6 +1285,33 @@
     shebang-command "^2.0.0"
     which "^2.0.1"
 
+data-view-buffer@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2"
+  integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==
+  dependencies:
+    call-bind "^1.0.6"
+    es-errors "^1.3.0"
+    is-data-view "^1.0.1"
+
+data-view-byte-length@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2"
+  integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==
+  dependencies:
+    call-bind "^1.0.7"
+    es-errors "^1.3.0"
+    is-data-view "^1.0.1"
+
+data-view-byte-offset@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a"
+  integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==
+  dependencies:
+    call-bind "^1.0.6"
+    es-errors "^1.3.0"
+    is-data-view "^1.0.1"
+
 debounce@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
@@ -1292,13 +1331,20 @@
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
+debug@^4.1.1, debug@^4.3.4:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
   integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
     ms "2.1.2"
 
+debug@^4.3.1, debug@^4.3.2:
+  version "4.3.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52"
+  integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==
+  dependencies:
+    ms "^2.1.3"
+
 decamelize-keys@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8"
@@ -1332,21 +1378,21 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
   integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
-define-data-property@^1.0.1, define-data-property@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3"
-  integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==
+define-data-property@^1.0.1, define-data-property@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
+  integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
   dependencies:
-    get-intrinsic "^1.2.1"
+    es-define-property "^1.0.0"
+    es-errors "^1.3.0"
     gopd "^1.0.1"
-    has-property-descriptors "^1.0.0"
 
 define-lazy-prop@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
   integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
 
-define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1:
+define-properties@^1.2.0, define-properties@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c"
   integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==
@@ -1484,66 +1530,92 @@
   dependencies:
     is-arrayish "^0.2.1"
 
-es-abstract@^1.22.1:
-  version "1.22.3"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.3.tgz#48e79f5573198de6dee3589195727f4f74bc4f32"
-  integrity sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==
+es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2:
+  version "1.23.3"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0"
+  integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==
   dependencies:
-    array-buffer-byte-length "^1.0.0"
-    arraybuffer.prototype.slice "^1.0.2"
-    available-typed-arrays "^1.0.5"
-    call-bind "^1.0.5"
-    es-set-tostringtag "^2.0.1"
+    array-buffer-byte-length "^1.0.1"
+    arraybuffer.prototype.slice "^1.0.3"
+    available-typed-arrays "^1.0.7"
+    call-bind "^1.0.7"
+    data-view-buffer "^1.0.1"
+    data-view-byte-length "^1.0.1"
+    data-view-byte-offset "^1.0.0"
+    es-define-property "^1.0.0"
+    es-errors "^1.3.0"
+    es-object-atoms "^1.0.0"
+    es-set-tostringtag "^2.0.3"
     es-to-primitive "^1.2.1"
     function.prototype.name "^1.1.6"
-    get-intrinsic "^1.2.2"
-    get-symbol-description "^1.0.0"
+    get-intrinsic "^1.2.4"
+    get-symbol-description "^1.0.2"
     globalthis "^1.0.3"
     gopd "^1.0.1"
-    has-property-descriptors "^1.0.0"
-    has-proto "^1.0.1"
+    has-property-descriptors "^1.0.2"
+    has-proto "^1.0.3"
     has-symbols "^1.0.3"
-    hasown "^2.0.0"
-    internal-slot "^1.0.5"
-    is-array-buffer "^3.0.2"
+    hasown "^2.0.2"
+    internal-slot "^1.0.7"
+    is-array-buffer "^3.0.4"
     is-callable "^1.2.7"
-    is-negative-zero "^2.0.2"
+    is-data-view "^1.0.1"
+    is-negative-zero "^2.0.3"
     is-regex "^1.1.4"
-    is-shared-array-buffer "^1.0.2"
+    is-shared-array-buffer "^1.0.3"
     is-string "^1.0.7"
-    is-typed-array "^1.1.12"
+    is-typed-array "^1.1.13"
     is-weakref "^1.0.2"
     object-inspect "^1.13.1"
     object-keys "^1.1.1"
-    object.assign "^4.1.4"
-    regexp.prototype.flags "^1.5.1"
-    safe-array-concat "^1.0.1"
-    safe-regex-test "^1.0.0"
-    string.prototype.trim "^1.2.8"
-    string.prototype.trimend "^1.0.7"
-    string.prototype.trimstart "^1.0.7"
-    typed-array-buffer "^1.0.0"
-    typed-array-byte-length "^1.0.0"
-    typed-array-byte-offset "^1.0.0"
-    typed-array-length "^1.0.4"
+    object.assign "^4.1.5"
+    regexp.prototype.flags "^1.5.2"
+    safe-array-concat "^1.1.2"
+    safe-regex-test "^1.0.3"
+    string.prototype.trim "^1.2.9"
+    string.prototype.trimend "^1.0.8"
+    string.prototype.trimstart "^1.0.8"
+    typed-array-buffer "^1.0.2"
+    typed-array-byte-length "^1.0.1"
+    typed-array-byte-offset "^1.0.2"
+    typed-array-length "^1.0.6"
     unbox-primitive "^1.0.2"
-    which-typed-array "^1.1.13"
+    which-typed-array "^1.1.15"
+
+es-define-property@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845"
+  integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==
+  dependencies:
+    get-intrinsic "^1.2.4"
+
+es-errors@^1.2.1, es-errors@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
+  integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
 
 es-module-lexer@^1.0.0:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5"
   integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==
 
-es-set-tostringtag@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz#11f7cc9f63376930a5f20be4915834f4bc74f9c9"
-  integrity sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==
+es-object-atoms@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941"
+  integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==
   dependencies:
-    get-intrinsic "^1.2.2"
-    has-tostringtag "^1.0.0"
-    hasown "^2.0.0"
+    es-errors "^1.3.0"
 
-es-shim-unscopables@^1.0.0:
+es-set-tostringtag@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777"
+  integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==
+  dependencies:
+    get-intrinsic "^1.2.4"
+    has-tostringtag "^1.0.2"
+    hasown "^2.0.1"
+
+es-shim-unscopables@^1.0.0, es-shim-unscopables@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763"
   integrity sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==
@@ -1621,10 +1693,10 @@
     is-core-module "^2.13.0"
     resolve "^1.22.4"
 
-eslint-module-utils@^2.8.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz#e439fee65fc33f6bba630ff621efc38ec0375c49"
-  integrity sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==
+eslint-module-utils@^2.12.0:
+  version "2.12.0"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz#fe4cfb948d61f49203d7b08871982b65b9af0b0b"
+  integrity sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==
   dependencies:
     debug "^3.2.7"
 
@@ -1643,27 +1715,29 @@
   dependencies:
     htmlparser2 "^8.0.1"
 
-eslint-plugin-import@^2.29.1:
-  version "2.29.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz#d45b37b5ef5901d639c15270d74d46d161150643"
-  integrity sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==
+eslint-plugin-import@^2.31.0:
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7"
+  integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==
   dependencies:
-    array-includes "^3.1.7"
-    array.prototype.findlastindex "^1.2.3"
+    "@rtsao/scc" "^1.1.0"
+    array-includes "^3.1.8"
+    array.prototype.findlastindex "^1.2.5"
     array.prototype.flat "^1.3.2"
     array.prototype.flatmap "^1.3.2"
     debug "^3.2.7"
     doctrine "^2.1.0"
     eslint-import-resolver-node "^0.3.9"
-    eslint-module-utils "^2.8.0"
-    hasown "^2.0.0"
-    is-core-module "^2.13.1"
+    eslint-module-utils "^2.12.0"
+    hasown "^2.0.2"
+    is-core-module "^2.15.1"
     is-glob "^4.0.3"
     minimatch "^3.1.2"
-    object.fromentries "^2.0.7"
-    object.groupby "^1.0.1"
-    object.values "^1.1.7"
+    object.fromentries "^2.0.8"
+    object.groupby "^1.0.3"
+    object.values "^1.2.0"
     semver "^6.3.1"
+    string.prototype.trimend "^1.0.8"
     tsconfig-paths "^3.15.0"
 
 eslint-plugin-jsdoc@^44.2.7:
@@ -1680,10 +1754,10 @@
     semver "^7.5.1"
     spdx-expression-parse "^3.0.1"
 
-eslint-plugin-lit@^1.11.0:
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.11.0.tgz#32fc1c58b476e5b9aa1c7b6ba9de295641bd4e9b"
-  integrity sha512-jVqy2juQTAtOzj1ILf+ZW5GpDobXlSw0kvpP2zu2r8ZbW7KISt7ikj1Gw9DhNeirEU1UlSJR0VIWpdr4lzjayw==
+eslint-plugin-lit@^1.15.0:
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.15.0.tgz#b03728313cb12130f4d264c442bd9f5d4f407d14"
+  integrity sha512-Yhr2MYNz6Ln8megKcX503aVZQln8wsywCG49g0heiJ/Qr5UjkE4pGr4Usez2anNcc7NvlvHbQWMYwWcgH3XRKA==
   dependencies:
     parse5 "^6.0.1"
     parse5-htmlparser2-tree-adapter "^6.0.1"
@@ -1753,16 +1827,16 @@
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
   integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
 
-eslint@^7.10.0, eslint@^8.49.0, eslint@^8.56.0:
-  version "8.56.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.56.0.tgz#4957ce8da409dc0809f99ab07a1b94832ab74b15"
-  integrity sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==
+eslint@^7.10.0, eslint@^8.57.1:
+  version "8.57.1"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9"
+  integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@eslint-community/regexpp" "^4.6.1"
     "@eslint/eslintrc" "^2.1.4"
-    "@eslint/js" "8.56.0"
-    "@humanwhocodes/config-array" "^0.11.13"
+    "@eslint/js" "8.57.1"
+    "@humanwhocodes/config-array" "^0.13.0"
     "@humanwhocodes/module-importer" "^1.0.1"
     "@nodelib/fs.walk" "^1.2.8"
     "@ungap/structured-clone" "^1.2.0"
@@ -1806,7 +1880,14 @@
     acorn-jsx "^5.3.2"
     eslint-visitor-keys "^3.4.1"
 
-esquery@^1.4.2, esquery@^1.5.0:
+esquery@^1.4.2:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7"
+  integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==
+  dependencies:
+    estraverse "^5.1.0"
+
+esquery@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b"
   integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==
@@ -1955,9 +2036,9 @@
   integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
 
 fastq@^1.6.0:
-  version "1.16.0"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320"
-  integrity sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
+  integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==
   dependencies:
     reusify "^1.0.4"
 
@@ -2025,9 +2106,9 @@
     rimraf "^3.0.2"
 
 flatted@^3.2.9:
-  version "3.2.9"
-  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf"
-  integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a"
+  integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==
 
 for-each@^0.3.3:
   version "0.3.3"
@@ -2088,11 +2169,12 @@
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.2.tgz#281b7622971123e1ef4b3c90fd7539306da93f3b"
-  integrity sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==
+get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd"
+  integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==
   dependencies:
+    es-errors "^1.3.0"
     function-bind "^1.1.2"
     has-proto "^1.0.1"
     has-symbols "^1.0.3"
@@ -2103,13 +2185,14 @@
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
   integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
 
-get-symbol-description@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
-  integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==
+get-symbol-description@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5"
+  integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==
   dependencies:
-    call-bind "^1.0.2"
-    get-intrinsic "^1.1.1"
+    call-bind "^1.0.5"
+    es-errors "^1.3.0"
+    get-intrinsic "^1.2.4"
 
 get-value@^2.0.3, get-value@^2.0.6:
   version "2.0.6"
@@ -2163,11 +2246,12 @@
     type-fest "^0.20.2"
 
 globalthis@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf"
-  integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236"
+  integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==
   dependencies:
-    define-properties "^1.1.3"
+    define-properties "^1.2.1"
+    gopd "^1.0.1"
 
 globby@^11.1.0:
   version "11.1.0"
@@ -2244,29 +2328,29 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
-has-property-descriptors@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz#52ba30b6c5ec87fd89fa574bc1c39125c6f65340"
-  integrity sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==
+has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
+  integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
   dependencies:
-    get-intrinsic "^1.2.2"
+    es-define-property "^1.0.0"
 
-has-proto@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0"
-  integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==
+has-proto@^1.0.1, has-proto@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd"
+  integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==
 
 has-symbols@^1.0.2, has-symbols@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
   integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
 
-has-tostringtag@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
-  integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
+has-tostringtag@^1.0.0, has-tostringtag@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
+  integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
   dependencies:
-    has-symbols "^1.0.2"
+    has-symbols "^1.0.3"
 
 has-value@^0.3.1:
   version "0.3.1"
@@ -2299,10 +2383,10 @@
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
-hasown@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
-  integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==
+hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
+  integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
   dependencies:
     function-bind "^1.1.2"
 
@@ -2369,11 +2453,16 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ignore@^5.1.1, ignore@^5.2.0:
+ignore@^5.1.1:
   version "5.3.0"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
   integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
 
+ignore@^5.2.0:
+  version "5.3.2"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
+  integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
+
 import-fresh@^3.2.1:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -2429,12 +2518,12 @@
     strip-ansi "^6.0.0"
     through "^2.3.6"
 
-internal-slot@^1.0.5:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.6.tgz#37e756098c4911c5e912b8edbf71ed3aa116f930"
-  integrity sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==
+internal-slot@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802"
+  integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==
   dependencies:
-    get-intrinsic "^1.2.2"
+    es-errors "^1.3.0"
     hasown "^2.0.0"
     side-channel "^1.0.4"
 
@@ -2450,14 +2539,13 @@
   dependencies:
     hasown "^2.0.0"
 
-is-array-buffer@^3.0.1, is-array-buffer@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe"
-  integrity sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==
+is-array-buffer@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98"
+  integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==
   dependencies:
     call-bind "^1.0.2"
-    get-intrinsic "^1.2.0"
-    is-typed-array "^1.1.10"
+    get-intrinsic "^1.2.1"
 
 is-arrayish@^0.2.1:
   version "0.2.1"
@@ -2503,7 +2591,14 @@
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
   integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
 
-is-core-module@^2.13.0, is-core-module@^2.13.1, is-core-module@^2.5.0:
+is-core-module@^2.13.0, is-core-module@^2.15.1:
+  version "2.15.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37"
+  integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==
+  dependencies:
+    hasown "^2.0.2"
+
+is-core-module@^2.5.0:
   version "2.13.1"
   resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
   integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
@@ -2517,6 +2612,13 @@
   dependencies:
     hasown "^2.0.0"
 
+is-data-view@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f"
+  integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==
+  dependencies:
+    is-typed-array "^1.1.13"
+
 is-date-object@^1.0.1:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f"
@@ -2593,10 +2695,10 @@
   resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
   integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
 
-is-negative-zero@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150"
-  integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==
+is-negative-zero@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747"
+  integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==
 
 is-number-object@^1.0.4:
   version "1.0.7"
@@ -2642,12 +2744,12 @@
     call-bind "^1.0.2"
     has-tostringtag "^1.0.0"
 
-is-shared-array-buffer@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79"
-  integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==
+is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688"
+  integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==
   dependencies:
-    call-bind "^1.0.2"
+    call-bind "^1.0.7"
 
 is-stream@^2.0.0:
   version "2.0.1"
@@ -2668,12 +2770,12 @@
   dependencies:
     has-symbols "^1.0.2"
 
-is-typed-array@^1.1.10, is-typed-array@^1.1.12, is-typed-array@^1.1.9:
-  version "1.1.12"
-  resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.12.tgz#d0bab5686ef4a76f7a73097b95470ab199c57d4a"
-  integrity sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==
+is-typed-array@^1.1.13:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229"
+  integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==
   dependencies:
-    which-typed-array "^1.1.11"
+    which-typed-array "^1.1.14"
 
 is-typedarray@^1.0.0:
   version "1.0.0"
@@ -3132,7 +3234,7 @@
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
-ms@^2.1.1:
+ms@^2.1.1, ms@^2.1.3:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@@ -3245,10 +3347,10 @@
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.13.1, object-inspect@^1.9.0:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
-  integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
+object-inspect@^1.13.1:
+  version "1.13.2"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff"
+  integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==
 
 object-keys@^1.1.1:
   version "1.1.1"
@@ -3262,7 +3364,7 @@
   dependencies:
     isobject "^3.0.0"
 
-object.assign@^4.1.4:
+object.assign@^4.1.5:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0"
   integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==
@@ -3272,24 +3374,24 @@
     has-symbols "^1.0.3"
     object-keys "^1.1.1"
 
-object.fromentries@^2.0.7:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.7.tgz#71e95f441e9a0ea6baf682ecaaf37fa2a8d7e616"
-  integrity sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==
+object.fromentries@^2.0.8:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65"
+  integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-abstract "^1.23.2"
+    es-object-atoms "^1.0.0"
 
-object.groupby@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.1.tgz#d41d9f3c8d6c778d9cbac86b4ee9f5af103152ee"
-  integrity sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==
+object.groupby@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e"
+  integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
-    get-intrinsic "^1.2.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-abstract "^1.23.2"
 
 object.pick@^1.3.0:
   version "1.3.0"
@@ -3298,14 +3400,14 @@
   dependencies:
     isobject "^3.0.1"
 
-object.values@^1.1.7:
-  version "1.1.7"
-  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.7.tgz#617ed13272e7e1071b43973aa1655d9291b8442a"
-  integrity sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==
+object.values@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b"
+  integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-object-atoms "^1.0.0"
 
 on-finished@^2.3.0:
   version "2.4.1"
@@ -3343,16 +3445,16 @@
     is-wsl "^2.2.0"
 
 optionator@^0.9.3:
-  version "0.9.3"
-  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
-  integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==
+  version "0.9.4"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
+  integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
   dependencies:
-    "@aashutoshrathi/word-wrap" "^1.2.3"
     deep-is "^0.1.3"
     fast-levenshtein "^2.0.6"
     levn "^0.4.1"
     prelude-ls "^1.2.1"
     type-check "^0.4.0"
+    word-wrap "^1.2.5"
 
 os-tmpdir@~1.0.2:
   version "1.0.2"
@@ -3515,6 +3617,11 @@
   resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
   integrity sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==
 
+possible-typed-array-names@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f"
+  integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==
+
 prelude-ls@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@@ -3622,14 +3729,15 @@
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
-regexp.prototype.flags@^1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz#90ce989138db209f81492edd734183ce99f9677e"
-  integrity sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==
+regexp.prototype.flags@^1.5.2:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz#b3ae40b1d2499b8350ab2c3fe6ef3845d3a96f42"
+  integrity sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    set-function-name "^2.0.0"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-errors "^1.3.0"
+    set-function-name "^2.0.2"
 
 regexpp@^3.0.0:
   version "3.2.0"
@@ -3713,13 +3821,20 @@
   dependencies:
     glob "^7.1.3"
 
-rollup@^2.67.0, rollup@^2.79.1:
+rollup@^2.67.0:
   version "2.79.1"
   resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
   integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
   optionalDependencies:
     fsevents "~2.3.2"
 
+rollup@^2.79.2:
+  version "2.79.2"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.2.tgz#f150e4a5db4b121a21a747d762f701e5e9f49090"
+  integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 run-async@^2.4.0:
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
@@ -3739,13 +3854,13 @@
   dependencies:
     tslib "^1.9.0"
 
-safe-array-concat@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c"
-  integrity sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==
+safe-array-concat@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb"
+  integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==
   dependencies:
-    call-bind "^1.0.2"
-    get-intrinsic "^1.2.1"
+    call-bind "^1.0.7"
+    get-intrinsic "^1.2.4"
     has-symbols "^1.0.3"
     isarray "^2.0.5"
 
@@ -3754,13 +3869,13 @@
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
-safe-regex-test@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295"
-  integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==
+safe-regex-test@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377"
+  integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==
   dependencies:
-    call-bind "^1.0.2"
-    get-intrinsic "^1.1.3"
+    call-bind "^1.0.6"
+    es-errors "^1.3.0"
     is-regex "^1.1.4"
 
 safe-regex@^1.1.0:
@@ -3802,24 +3917,27 @@
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
   integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
 
-set-function-length@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.1.1.tgz#4bc39fafb0307224a33e106a7d35ca1218d659ed"
-  integrity sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==
+set-function-length@^1.2.1:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
+  integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
   dependencies:
-    define-data-property "^1.1.1"
-    get-intrinsic "^1.2.1"
+    define-data-property "^1.1.4"
+    es-errors "^1.3.0"
+    function-bind "^1.1.2"
+    get-intrinsic "^1.2.4"
     gopd "^1.0.1"
-    has-property-descriptors "^1.0.0"
+    has-property-descriptors "^1.0.2"
 
-set-function-name@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a"
-  integrity sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==
+set-function-name@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985"
+  integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==
   dependencies:
-    define-data-property "^1.0.1"
+    define-data-property "^1.1.4"
+    es-errors "^1.3.0"
     functions-have-names "^1.2.3"
-    has-property-descriptors "^1.0.0"
+    has-property-descriptors "^1.0.2"
 
 set-value@^2.0.0, set-value@^2.0.1:
   version "2.0.1"
@@ -3871,13 +3989,14 @@
   integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
 
 side-channel@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
-  integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
+  integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==
   dependencies:
-    call-bind "^1.0.0"
-    get-intrinsic "^1.0.2"
-    object-inspect "^1.9.0"
+    call-bind "^1.0.7"
+    es-errors "^1.3.0"
+    get-intrinsic "^1.2.4"
+    object-inspect "^1.13.1"
 
 signal-exit@^3.0.2, signal-exit@^3.0.3:
   version "3.0.7"
@@ -4035,32 +4154,33 @@
     define-properties "^1.2.0"
     es-abstract "^1.22.1"
 
-string.prototype.trim@^1.2.8:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd"
-  integrity sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==
+string.prototype.trim@^1.2.9:
+  version "1.2.9"
+  resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4"
+  integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-abstract "^1.23.0"
+    es-object-atoms "^1.0.0"
 
-string.prototype.trimend@^1.0.7:
-  version "1.0.7"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz#1bb3afc5008661d73e2dc015cd4853732d6c471e"
-  integrity sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==
+string.prototype.trimend@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229"
+  integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-object-atoms "^1.0.0"
 
-string.prototype.trimstart@^1.0.7:
-  version "1.0.7"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz#d4cdb44b83a4737ffbac2d406e405d43d0184298"
-  integrity sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==
+string.prototype.trimstart@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde"
+  integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.2.0"
-    es-abstract "^1.22.1"
+    call-bind "^1.0.7"
+    define-properties "^1.2.1"
+    es-object-atoms "^1.0.0"
 
 strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
@@ -4277,44 +4397,49 @@
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
-typed-array-buffer@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz#18de3e7ed7974b0a729d3feecb94338d1472cd60"
-  integrity sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==
+typed-array-buffer@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3"
+  integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==
   dependencies:
-    call-bind "^1.0.2"
-    get-intrinsic "^1.2.1"
-    is-typed-array "^1.1.10"
+    call-bind "^1.0.7"
+    es-errors "^1.3.0"
+    is-typed-array "^1.1.13"
 
-typed-array-byte-length@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz#d787a24a995711611fb2b87a4052799517b230d0"
-  integrity sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==
+typed-array-byte-length@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67"
+  integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==
   dependencies:
-    call-bind "^1.0.2"
+    call-bind "^1.0.7"
     for-each "^0.3.3"
-    has-proto "^1.0.1"
-    is-typed-array "^1.1.10"
+    gopd "^1.0.1"
+    has-proto "^1.0.3"
+    is-typed-array "^1.1.13"
 
-typed-array-byte-offset@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz#cbbe89b51fdef9cd6aaf07ad4707340abbc4ea0b"
-  integrity sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==
+typed-array-byte-offset@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063"
+  integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==
   dependencies:
-    available-typed-arrays "^1.0.5"
-    call-bind "^1.0.2"
+    available-typed-arrays "^1.0.7"
+    call-bind "^1.0.7"
     for-each "^0.3.3"
-    has-proto "^1.0.1"
-    is-typed-array "^1.1.10"
+    gopd "^1.0.1"
+    has-proto "^1.0.3"
+    is-typed-array "^1.1.13"
 
-typed-array-length@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb"
-  integrity sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==
+typed-array-length@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3"
+  integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==
   dependencies:
-    call-bind "^1.0.2"
+    call-bind "^1.0.7"
     for-each "^0.3.3"
-    is-typed-array "^1.1.9"
+    gopd "^1.0.1"
+    has-proto "^1.0.3"
+    is-typed-array "^1.1.13"
+    possible-typed-array-names "^1.0.0"
 
 typedarray-to-buffer@^3.1.5:
   version "3.1.5"
@@ -4490,16 +4615,16 @@
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
   integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
 
-which-typed-array@^1.1.11, which-typed-array@^1.1.13:
-  version "1.1.13"
-  resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.13.tgz#870cd5be06ddb616f504e7b039c4c24898184d36"
-  integrity sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==
+which-typed-array@^1.1.14, which-typed-array@^1.1.15:
+  version "1.1.15"
+  resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d"
+  integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==
   dependencies:
-    available-typed-arrays "^1.0.5"
-    call-bind "^1.0.4"
+    available-typed-arrays "^1.0.7"
+    call-bind "^1.0.7"
     for-each "^0.3.3"
     gopd "^1.0.1"
-    has-tostringtag "^1.0.0"
+    has-tostringtag "^1.0.2"
 
 which@^1.2.9:
   version "1.3.1"
@@ -4515,6 +4640,11 @@
   dependencies:
     isexe "^2.0.0"
 
+word-wrap@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
+  integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
+
 wordwrapjs@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.0.tgz#4c4d20446dcc670b14fa115ef4f8fd9947af2b3a"