Merge "Migrate deprecated JavaInfo API usages"
diff --git a/.mailmap b/.mailmap
deleted file mode 100644
index 721f3c0..0000000
--- a/.mailmap
+++ /dev/null
@@ -1,97 +0,0 @@
-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/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 4d8965a..32c05b0 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1415,6 +1415,13 @@
 +
 The default limit is 16kiB.
 
+[[change.topicLimit]]change.topicLimit::
++
+Maximum allowed number of changes with the same topic. 0 or negative values
+mean "unlimited".
++
+By default 5,000.
+
 [[change.cumulativeCommentSizeLimit]]change.cumulativeCommentSizeLimit::
 +
 Maximum allowed size in characters of all comments (including robot comments)
@@ -1443,6 +1450,16 @@
 +
 By default 100,000.
 
+[[change.maxFileSizeDownload]]change.maxFileSizeDownload::
++
+The link:rest-api-changes.html#get-content[GetContent] and
+link:rest-api-changes.html#get-safe-content[DownloadContent] REST APIs will
+refuse to load files larger than this limit (in bytes). 0 or negative values
+mean "unlimited".
+
++
+By default 0 (unlimited).
+
 [[change.maxPatchSets]]change.maxPatchSets::
 +
 Maximum number of patch sets allowed per change. If this is insufficient,
@@ -1655,6 +1672,16 @@
 +
 Default is 5 minutes.
 
+[[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.
+This setting takes effect when generating the automerge, which happens on upload.
+Changing the setting leaves existing changes unaffected.
++
+Default is false.
+
 [[changeCleanup]]
 === Section changeCleanup
 
@@ -3397,6 +3424,15 @@
 +
 Defaults to `OFFSET`.
 
+[[index.defaultLimit]]index.defaultLimit::
++
+Default limit, if the user does not provide a limit. If this is not set or set
+to 0, then index queries are executed with the maximum permitted limit for the
+user, which may be really high and cause too much load on the index. Thus
+setting this default limit to something smaller like 100 allows you to control
+the load, while not taking away any permission from the user. If the user
+provides a limit themselves, then `defaultLimit` is ignored.
+
 [[index.maxLimit]]index.maxLimit::
 +
 Maximum limit to allow for search queries. Requesting results above this
@@ -3898,11 +3934,12 @@
 example to join given name and surname together, use the pattern
 `${givenName} ${SN}`.
 +
-If set, users will be unable to modify their full name field, as
-Gerrit will populate it only from the LDAP data.
-+
 Default is `displayName` for FreeIPA and RFC 2307 servers,
 and `${givenName} ${sn}` for Active Directory.
++
+A non-empty or default value prevents users from modifying their full
+name field.  To allow edits to the full name field, set to the empty
+string.
 
 [[ldap.accountEmailAddress]]ldap.accountEmailAddress::
 +
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 1accef0..218affd 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -317,7 +317,9 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-[[content_merge]]submit.mergeContent::
+[[content_merge]]
+
+[[submit.mergeContent]]submit.mergeContent::
 +
 Defines whether Gerrit will try to do a content merge when a path conflict
 occurs while submitting a change.
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 015faab..cc7cbd0 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -457,6 +457,10 @@
 +
 Update of the change secondary index
 
+* `com.google.gerrit.server.extensions.events.CustomKeyedValueValidationListener`:
++
+Updates to custom keyed values
+
 * `com.google.gerrit.server.extensions.events.AccountIndexedListener`:
 +
 Update of the account secondary index
@@ -3036,6 +3040,35 @@
 `com.google.gerrit.server.RequestListener` is an extension point that is
 invoked each time the server executes a request from a user.
 
+[[custom-keyed-values]]
+== Custom Keyed values
+
+It is possible to associate custom keyed values with a change. This is a map
+from string to string, allowing for the storage of small keys and values. An
+example would be for storing an associated workspace with the given change.
+
+As an example:
+```
+  private void setWorkspace(ChangeResource rsrc, String workspaceId)
+      throws RestApiException, UpdateException {
+    try (RefUpdateContext pluginCtx = RefUpdateContext.open(PLUGIN);
+        RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION);
+        BatchUpdate bu =
+            updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+      SetCustomKeyedValuesOp op =
+          customKeyedValuesFactory.create(
+              new CustomKeyedValuesInput(ImmutableMap.of("workspace", workspaceId)));
+      bu.addOp(rsrc.getId(), op);
+      bu.execute();
+    }
+  }
+```
+
+These custom-keyed-values can be fetched by passing the option `o=CUSTOM_KEYED_VALUES`
+to a change details fetch.
+
+
+
 == SEE ALSO
 
 * link:pg-plugin-dev.html[JavaScript Plugin API]
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 2499a15..5e6a540 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -99,6 +99,7 @@
 * javaewah
 * jsr305
 * mime-util
+* roaringbitmap
 * servlet-api
 * servlet-api-without-neverlink
 * soy
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 60f71c9..844f4c6 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -986,6 +986,10 @@
 MergePatchSetInput and add a new patch set to the change corresponding
 to the new merge commit.
 
+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
+new patch set; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
@@ -1039,6 +1043,10 @@
 
 Creates a new patch set with a new commit message.
 
+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
+new patch set; otherwise, the user's preferred email will be used.
+
 The new commit message must be provided in the request body inside a
 link:#commit-message-input[CommitMessageInput] entity. If a Change-Id
 footer is specified, it must match the current Change-Id footer. If
@@ -1314,6 +1322,10 @@
 
 Rebases a change.
 
+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
+new patch set; otherwise, the user's preferred email will be used.
+
 Optionally, the parent revision can be changed to another patch set through the
 link:#rebase-input[RebaseInput] entity.
 
@@ -1440,6 +1452,10 @@
 
 Rebases an ancestry chain of changes.
 
+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
+new patch set; otherwise, the user's preferred email will be used.
+
 The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
 
 Requires a linear ancestry relation (single parenting throughout the chain).
@@ -1919,6 +1935,11 @@
 
 Submits a change.
 
+If the submission results in a new patch set (due to a rebase or cherry-pick merge method), the
+committer email will remain the same as the one used in the previous commit, provided that one of
+the secondary emails associated with the user performing the operation was used as the committer
+email in the previous commit. Otherwise, the user's preferred email will be used.
+
 The request body only needs to include a link:#submit-input[
 SubmitInput] entity if submitting on behalf of another user.
 
@@ -2276,6 +2297,10 @@
 
 Creates a new patch set on a destination change from the provided patch.
 
+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
+new patch set; otherwise, the user's preferred email will be used.
+
 The patch must be provided in the request body, inside a
 link:#applypatchpatchset-input[ApplyPatchPatchSetInput] entity.
 
@@ -2855,14 +2880,14 @@
 [[get-custom-keyed-values]]
 === Get Custom Keyed Values
 --
-'GET /changes/link:#change-id[\{change-id\}]/custom-keyed-values'
+'GET /changes/link:#change-id[\{change-id\}]/custom_keyed_values'
 --
 
 Gets the custom keyed values associated with a change.
 
 .Request
 ----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom-keyed-values HTTP/1.0
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom_keyed_values HTTP/1.0
 ----
 
 As response the change's custom keyed values are returned as a map of strings.
@@ -2883,7 +2908,7 @@
 [[set-custom-keyed-values]]
 === Set Custom Keyed Values
 --
-'POST /changes/link:#change-id[\{change-id\}]/custom-keyed-values'
+'POST /changes/link:#change-id[\{change-id\}]/custom_keyed_values'
 --
 
 Adds and/or removes custom keyed values from a change.
@@ -2893,7 +2918,7 @@
 
 .Request
 ----
-  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom-keyed-values HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom_keyed_values HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
   {
@@ -3183,11 +3208,15 @@
 [[put-edit-file]]
 === Change file content in Change Edit
 --
-'PUT /changes/link:#change-id[\{change-id\}]/edit/path%2fto%2ffile
+'PUT /changes/link:#change-id[\{change-id\}]/edit/path%2fto%2ffile'
 --
 
 Put content of a file to a change edit.
 
+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.
+
 .Request
 ----
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
@@ -3234,7 +3263,7 @@
 [[post-edit]]
 === Restore file content or rename files in Change Edit
 --
-'POST /changes/link:#change-id[\{change-id\}]/edit
+'POST /changes/link:#change-id[\{change-id\}]/edit'
 --
 
 Creates empty change edit, restores file content or renames files in change
@@ -3242,6 +3271,10 @@
 link:#change-edit-input[ChangeEditInput] entity when a file within change
 edit should be restored or old and new file names to rename a file.
 
+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.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit HTTP/1.0
@@ -3278,13 +3311,17 @@
 [[put-change-edit-message]]
 === Change commit message in Change Edit
 --
-'PUT /changes/link:#change-id[\{change-id\}]/edit:message
+'PUT /changes/link:#change-id[\{change-id\}]/edit:message'
 --
 
 Modify commit message. The request body needs to include a
 link:#change-edit-message-input[ChangeEditMessageInput]
 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.
+
 .Request
 ----
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:message HTTP/1.0
@@ -3313,6 +3350,10 @@
 completely. This is not the same as reverting or restoring a file to its
 previous contents.
 
+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.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
@@ -3480,11 +3521,15 @@
 [[rebase-edit]]
 === Rebase Change Edit
 --
-'POST /changes/link:#change-id[\{change-id\}]/edit:rebase
+'POST /changes/link:#change-id[\{change-id\}]/edit:rebase'
 --
 
 Rebases change edit on top of latest patch set.
 
+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.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:rebase HTTP/1.0
@@ -5594,6 +5639,10 @@
 exists and the fix refers to the current patch set, or the fix refers to the
 patch set on which the change edit is based.
 
+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
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fixes/8f605a55_f6aa4ecc/apply HTTP/1.0
@@ -5660,6 +5709,10 @@
 application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
 patchset.
 
+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
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fix:apply HTTP/1.0
@@ -6315,6 +6368,10 @@
 
 Cherry picks a revision to a destination branch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the original revision, the same email will be used as the committer email
+in the new patch set; otherwise, the user's preferred email will be used.
+
 To cherry pick a commit with no change-id associated with it, see
 link:rest-api-projects.html#cherry-pick-commit[CherryPickCommit].
 
@@ -8133,6 +8190,14 @@
 The caller needs "Forge Author" permission when using this field.
 This field does not affect the owner or the committer of the change, which will
 continue to use the identity of the caller.
+|`validation_options`   |optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |==================================
 
 [[move-input]]
@@ -8572,6 +8637,8 @@
 If set to true, ignore all automatic attention set rules described in the
 link:#attention-set[attention set]. Updates in add_to_attention_set
 and remove_from_attention_set are not ignored.
+|`response_format_options`     |optional|
+List of link:#query-options[query options] to format the response.
 |============================
 
 [[review-result]]
@@ -8595,6 +8662,9 @@
 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.
 |============================
 
 [[reviewer-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 9e71df7..73fac68 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -139,11 +139,16 @@
   )]}'
   {
     "some-project": {
-      "id": "some-project"
+      "id": "some-project",
+      _more_projects: true
     }
   }
 ----
 
+If the number of projects matching the query exceeds either the
+internal limit or a supplied `limit` query parameter, the last project
+object has a `_more_projects: true` JSON field set.
+
 
 [[suggest-projects]]
 Prefix(p)::
@@ -420,6 +425,10 @@
   GET /projects/?query=<query>&limit=25 HTTP/1.0
 ----
 
+If the number of projects matching the query exceeds either the
+internal limit or a supplied `limit` query parameter, the last project
+object has a `_more_projects: true` JSON field set.
+
 The `/projects/` URL also accepts a start integer in the `start`
 parameter. The results will skip `start` projects from project list.
 
@@ -2658,6 +2667,10 @@
 
 Cherry-picks a commit of a project to a destination branch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the original commit, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 To cherry pick a change revision, see link:rest-api-changes.html#cherry-pick[CherryPick].
 
 The destination branch must be provided in the request body inside a
@@ -4370,28 +4383,31 @@
 The `ProjectInfo` entity contains information about a project.
 
 [options="header",cols="1,^2,4"]
-|===========================
-|Field Name    ||Description
-|`id`          ||The URL encoded project name.
-|`name`        |
+|=============================
+|Field Name      ||Description
+|`id`            ||The URL encoded project name.
+|`name`          |
 not set if returned in a map where the project name is used as map key|
 The name of the project.
-|`parent`      |optional|
+|`parent`        |optional|
 The name of the parent project. +
 `?-<n>` if the parent project is not visible (`<n>` is a number which
 is increased for each non-visible project).
-|`description` |optional|The description of the project.
-|`state`       |optional|`ACTIVE`, `READ_ONLY` or `HIDDEN`.
-|`branches`    |optional|Map of branch names to HEAD revisions.
-|`labels`      |optional|
+|`description`   |optional|The description of the project.
+|`state`         |optional|`ACTIVE`, `READ_ONLY` or `HIDDEN`.
+|`branches`      |optional|Map of branch names to HEAD revisions.
+|`labels`        |optional|
 Map of label names to
 link:#label-type-info[LabelTypeInfo] entries.
 This field is filled for link:#create-project[Create Project] and
 link:#get-project[Get Project] calls.
-|`web_links`   |optional|
+|`web_links`     |optional|
 Links to the project in external sites as a list of
 link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
-|===========================
+|`_more_projects`|optional, not set if `false`|
+Whether the query would deliver more results if not limited. +
+Only set on the last project that is returned.
+|=============================
 
 [[project-input]]
 === ProjectInput
diff --git a/Documentation/user-search-projects.txt b/Documentation/user-search-projects.txt
index 8ebbf3e..aabbd55 100644
--- a/Documentation/user-search-projects.txt
+++ b/Documentation/user-search-projects.txt
@@ -12,6 +12,17 @@
 +
 Matches projects that have exactly the name 'NAME'.
 
+[[prefix]]
+prefix:'PREFIX'::
++
+Matches projects that have a name that starts with 'PREFIX' (may be
+case-sensitive, depending on which index backend is used).
+
+[[substring]]
+substring:'SUBSTRING'::
++
+Matches projects that have a name that contains 'SUBSTRING' (case-insensitive).
+
 [[parent]]
 parent:'PARENT'::
 +
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 5d52179..a0cda1a 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -582,6 +582,7 @@
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setBoolean("sendemail", null, "enable", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
+    cfg.setInt("execution", null, "fanOutThreadPoolSize", 0);
     cfg.setInt("plugins", null, "checkFrequency", 0);
 
     cfg.setInt("sshd", null, "threads", 1);
diff --git a/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
index a603328..740df21 100644
--- a/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
@@ -14,21 +14,21 @@
 
 package com.google.gerrit.extensions.api.changes;
 
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
+import java.util.Set;
 
 public class CustomKeyedValuesInput {
-  @DefaultInput public ImmutableMap<String, String> add;
-  public ImmutableSet<String> remove;
+  @DefaultInput public Map<String, String> add;
+  public Set<String> remove;
 
   public CustomKeyedValuesInput() {}
 
-  public CustomKeyedValuesInput(ImmutableMap<String, String> add) {
+  public CustomKeyedValuesInput(Map<String, String> add) {
     this.add = add;
   }
 
-  public CustomKeyedValuesInput(ImmutableMap<String, String> add, ImmutableSet<String> remove) {
+  public CustomKeyedValuesInput(Map<String, String> add, Set<String> remove) {
     this(add);
     this.remove = remove;
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 8bfe468..98807cb 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -18,7 +18,9 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -96,6 +98,8 @@
    */
   public boolean ignoreAutomaticAttentionSetRules;
 
+  @Nullable public List<ListChangesOption> responseFormatOptions;
+
   public enum DraftHandling {
     /** Leave pending drafts alone. */
     KEEP,
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
index 95bea5b..bd22ca8 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import java.util.Map;
 
 /** Result object representing the outcome of a review request. */
@@ -38,4 +39,7 @@
 
   /** Error message for non-200 responses. */
   @Nullable public String error;
+
+  /** Change after applying the update. */
+  @Nullable public ChangeInfo changeInfo;
 }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 6d52a93..109afd6 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
 public class DiffPreferencesInfo {
 
   /** Default number of lines of context. */
@@ -60,6 +63,97 @@
   public Boolean skipUnchanged;
   public Boolean skipUncommented;
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof DiffPreferencesInfo)) {
+      return false;
+    }
+    DiffPreferencesInfo other = (DiffPreferencesInfo) obj;
+    return Objects.equals(this.context, other.context)
+        && Objects.equals(this.tabSize, other.tabSize)
+        && Objects.equals(this.fontSize, other.fontSize)
+        && Objects.equals(this.lineLength, other.lineLength)
+        && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate)
+        && Objects.equals(this.expandAllComments, other.expandAllComments)
+        && Objects.equals(this.intralineDifference, other.intralineDifference)
+        && Objects.equals(this.manualReview, other.manualReview)
+        && Objects.equals(this.showLineEndings, other.showLineEndings)
+        && Objects.equals(this.showTabs, other.showTabs)
+        && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors)
+        && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting)
+        && Objects.equals(this.hideTopMenu, other.hideTopMenu)
+        && Objects.equals(this.autoHideDiffTableHeader, other.autoHideDiffTableHeader)
+        && Objects.equals(this.hideLineNumbers, other.hideLineNumbers)
+        && Objects.equals(this.renderEntireFile, other.renderEntireFile)
+        && Objects.equals(this.hideEmptyPane, other.hideEmptyPane)
+        && Objects.equals(this.matchBrackets, other.matchBrackets)
+        && Objects.equals(this.lineWrapping, other.lineWrapping)
+        && Objects.equals(this.ignoreWhitespace, other.ignoreWhitespace)
+        && Objects.equals(this.retainHeader, other.retainHeader)
+        && Objects.equals(this.skipDeleted, other.skipDeleted)
+        && Objects.equals(this.skipUnchanged, other.skipUnchanged)
+        && Objects.equals(this.skipUncommented, other.skipUncommented);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        context,
+        tabSize,
+        fontSize,
+        lineLength,
+        cursorBlinkRate,
+        expandAllComments,
+        intralineDifference,
+        manualReview,
+        showLineEndings,
+        showTabs,
+        showWhitespaceErrors,
+        syntaxHighlighting,
+        hideTopMenu,
+        autoHideDiffTableHeader,
+        hideLineNumbers,
+        renderEntireFile,
+        hideEmptyPane,
+        matchBrackets,
+        lineWrapping,
+        ignoreWhitespace,
+        retainHeader,
+        skipDeleted,
+        skipUnchanged,
+        skipUncommented);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("DiffPreferencesInfo")
+        .add("context", context)
+        .add("tabSize", tabSize)
+        .add("fontSize", fontSize)
+        .add("lineLength", lineLength)
+        .add("cursorBlinkRate", cursorBlinkRate)
+        .add("expandAllComments", expandAllComments)
+        .add("intralineDifference", intralineDifference)
+        .add("manualReview", manualReview)
+        .add("showLineEndings", showLineEndings)
+        .add("showTabs", showTabs)
+        .add("showWhitespaceErrors", showWhitespaceErrors)
+        .add("syntaxHighlighting", syntaxHighlighting)
+        .add("hideTopMenu", hideTopMenu)
+        .add("autoHideDiffTableHeader", autoHideDiffTableHeader)
+        .add("hideLineNumbers", hideLineNumbers)
+        .add("renderEntireFile", renderEntireFile)
+        .add("hideEmptyPane", hideEmptyPane)
+        .add("matchBrackets", matchBrackets)
+        .add("lineWrapping", lineWrapping)
+        .add("ignoreWhitespace", ignoreWhitespace)
+        .add("retainHeader", retainHeader)
+        .add("skipDeleted", skipDeleted)
+        .add("skipUnchanged", skipUnchanged)
+        .add("skipUncommented", skipUncommented)
+        .toString();
+  }
+
   public static DiffPreferencesInfo defaults() {
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.context = DEFAULT_CONTEXT;
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 6672cb1..0a3ec0a 100644
--- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
 /* This class is stored in Git config file. */
 public class EditPreferencesInfo {
   public Integer tabSize;
@@ -31,6 +34,67 @@
   public Boolean autoCloseBrackets;
   public Boolean showBase;
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof EditPreferencesInfo)) {
+      return false;
+    }
+    EditPreferencesInfo other = (EditPreferencesInfo) obj;
+    return Objects.equals(this.tabSize, other.tabSize)
+        && Objects.equals(this.lineLength, other.lineLength)
+        && Objects.equals(this.indentUnit, other.indentUnit)
+        && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate)
+        && Objects.equals(this.hideTopMenu, other.hideTopMenu)
+        && Objects.equals(this.showTabs, other.showTabs)
+        && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors)
+        && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting)
+        && Objects.equals(this.hideLineNumbers, other.hideLineNumbers)
+        && Objects.equals(this.matchBrackets, other.matchBrackets)
+        && Objects.equals(this.lineWrapping, other.lineWrapping)
+        && Objects.equals(this.indentWithTabs, other.indentWithTabs)
+        && Objects.equals(this.autoCloseBrackets, other.autoCloseBrackets)
+        && Objects.equals(this.showBase, other.showBase);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        tabSize,
+        lineLength,
+        indentUnit,
+        cursorBlinkRate,
+        hideTopMenu,
+        showTabs,
+        showWhitespaceErrors,
+        syntaxHighlighting,
+        hideLineNumbers,
+        matchBrackets,
+        lineWrapping,
+        indentWithTabs,
+        autoCloseBrackets,
+        showBase);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("EditPreferencesInfo")
+        .add("tabSize", tabSize)
+        .add("lineLength", lineLength)
+        .add("indentUnit", indentUnit)
+        .add("cursorBlinkRate", cursorBlinkRate)
+        .add("hideTopMenu", hideTopMenu)
+        .add("showTabs", showTabs)
+        .add("showWhitespaceErrors", showWhitespaceErrors)
+        .add("syntaxHighlighting", syntaxHighlighting)
+        .add("hideLineNumbers", hideLineNumbers)
+        .add("matchBrackets", matchBrackets)
+        .add("lineWrapping", lineWrapping)
+        .add("indentWithTabs", indentWithTabs)
+        .add("autoCloseBrackets", autoCloseBrackets)
+        .add("showBase", showBase)
+        .toString();
+  }
+
   public static EditPreferencesInfo defaults() {
     EditPreferencesInfo i = new EditPreferencesInfo();
     i.tabSize = 8;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 020351b..5b33aca 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
 import java.util.List;
+import java.util.Objects;
 
 /** Preferences about a single user. */
 public class GeneralPreferencesInfo {
@@ -22,16 +24,6 @@
   /** Default number of items to display per page. */
   public static final int DEFAULT_PAGESIZE = 25;
 
-  /** Preferred method to download a change. */
-  public enum DownloadCommand {
-    PULL,
-    CHECKOUT,
-    CHERRY_PICK,
-    FORMAT_PATCH,
-    BRANCH,
-    RESET,
-  }
-
   public enum DateFormat {
     /** US style dates: Apr 27, Feb 14, 2010 */
     STD("MMM d", "MMM d, yyyy"),
@@ -186,6 +178,91 @@
     return emailFormat;
   }
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof GeneralPreferencesInfo)) {
+      return false;
+    }
+    GeneralPreferencesInfo other = (GeneralPreferencesInfo) obj;
+    return Objects.equals(this.changesPerPage, other.changesPerPage)
+        && Objects.equals(this.downloadScheme, other.downloadScheme)
+        && Objects.equals(this.theme, other.theme)
+        && Objects.equals(this.dateFormat, other.dateFormat)
+        && Objects.equals(this.timeFormat, other.timeFormat)
+        && Objects.equals(this.expandInlineDiffs, other.expandInlineDiffs)
+        && Objects.equals(this.relativeDateInChangeTable, other.relativeDateInChangeTable)
+        && Objects.equals(this.diffView, other.diffView)
+        && Objects.equals(this.sizeBarInChangeTable, other.sizeBarInChangeTable)
+        && Objects.equals(this.legacycidInChangeTable, other.legacycidInChangeTable)
+        && Objects.equals(this.muteCommonPathPrefixes, other.muteCommonPathPrefixes)
+        && Objects.equals(this.signedOffBy, other.signedOffBy)
+        && Objects.equals(this.emailStrategy, other.emailStrategy)
+        && Objects.equals(this.emailFormat, other.emailFormat)
+        && Objects.equals(this.defaultBaseForMerges, other.defaultBaseForMerges)
+        && Objects.equals(this.publishCommentsOnPush, other.publishCommentsOnPush)
+        && Objects.equals(this.disableKeyboardShortcuts, other.disableKeyboardShortcuts)
+        && Objects.equals(this.disableTokenHighlighting, other.disableTokenHighlighting)
+        && Objects.equals(this.workInProgressByDefault, other.workInProgressByDefault)
+        && Objects.equals(this.my, other.my)
+        && Objects.equals(this.changeTable, other.changeTable)
+        && Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        changesPerPage,
+        downloadScheme,
+        theme,
+        dateFormat,
+        timeFormat,
+        expandInlineDiffs,
+        relativeDateInChangeTable,
+        diffView,
+        sizeBarInChangeTable,
+        legacycidInChangeTable,
+        muteCommonPathPrefixes,
+        signedOffBy,
+        emailStrategy,
+        emailFormat,
+        defaultBaseForMerges,
+        publishCommentsOnPush,
+        disableKeyboardShortcuts,
+        disableTokenHighlighting,
+        workInProgressByDefault,
+        my,
+        changeTable,
+        allowBrowserNotifications);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("GeneralPreferencesInfo")
+        .add("changesPerPage", changesPerPage)
+        .add("downloadScheme", downloadScheme)
+        .add("theme", theme)
+        .add("dateFormat", dateFormat)
+        .add("timeFormat", timeFormat)
+        .add("expandInlineDiffs", expandInlineDiffs)
+        .add("relativeDateInChangeTable", relativeDateInChangeTable)
+        .add("diffView", diffView)
+        .add("sizeBarInChangeTable", sizeBarInChangeTable)
+        .add("legacycidInChangeTable", legacycidInChangeTable)
+        .add("muteCommonPathPrefixes", muteCommonPathPrefixes)
+        .add("signedOffBy", signedOffBy)
+        .add("emailStrategy", emailStrategy)
+        .add("emailFormat", emailFormat)
+        .add("defaultBaseForMerges", defaultBaseForMerges)
+        .add("publishCommentsOnPush", publishCommentsOnPush)
+        .add("disableKeyboardShortcuts", disableKeyboardShortcuts)
+        .add("disableTokenHighlighting", disableTokenHighlighting)
+        .add("workInProgressByDefault", workInProgressByDefault)
+        .add("my", my)
+        .add("changeTable", changeTable)
+        .add("allowBrowserNotifications", allowBrowserNotifications)
+        .toString();
+  }
+
   public static GeneralPreferencesInfo defaults() {
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 734d7e9..4a769dd 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import java.util.Map;
 
 public class MergePatchSetInput {
   public String subject;
@@ -22,4 +23,5 @@
   public String baseChange;
   public MergeInput merge;
   public AccountInput author;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/common/ProjectInfo.java b/java/com/google/gerrit/extensions/common/ProjectInfo.java
index 46b2599..2b00710 100644
--- a/java/com/google/gerrit/extensions/common/ProjectInfo.java
+++ b/java/com/google/gerrit/extensions/common/ProjectInfo.java
@@ -27,4 +27,10 @@
   public Map<String, String> branches;
   public List<WebLinkInfo> webLinks;
   public Map<String, LabelTypeInfo> labels;
+
+  /**
+   * Whether the query would deliver more results if not limited. Only set on the last project that
+   * is returned as a query result.
+   */
+  public Boolean _moreProjects;
 }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index fb28d30..1d9a0af 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.config.Server;
-import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
@@ -95,8 +94,6 @@
       case CHANGE:
       case DIFF:
         data.put(
-            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
-        data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
@@ -121,7 +118,6 @@
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
       if (page == RequestedPage.DASHBOARD) {
-        data.put("defaultDashboardHex", ListOption.toHex(IndexPreloadingUtil.DASHBOARD_OPTIONS));
         data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList());
       }
     } catch (AuthException e) {
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 36fa61b..3ada18d 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -17,12 +17,10 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
-import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.restapi.Url;
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -83,25 +81,6 @@
               NEW_USER)
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
-  public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
-      ImmutableSet.of(
-          ListChangesOption.LABELS,
-          ListChangesOption.DETAILED_ACCOUNTS,
-          ListChangesOption.SUBMIT_REQUIREMENTS,
-          ListChangesOption.STAR);
-
-  public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
-      ImmutableSet.of(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS,
-          ListChangesOption.CHANGE_ACTIONS,
-          ListChangesOption.DETAILED_LABELS,
-          ListChangesOption.DOWNLOAD_COMMANDS,
-          ListChangesOption.MESSAGES,
-          ListChangesOption.SUBMITTABLE,
-          ListChangesOption.WEB_LINKS,
-          ListChangesOption.SKIP_DIFFSTAT,
-          ListChangesOption.SUBMIT_REQUIREMENTS);
 
   @Nullable
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index 870d827..3ed76ba 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.Optional;
@@ -158,12 +159,14 @@
   }
 
   /**
-   * Rewriter that should be invoked on queries to this index.
+   * An Optional filter that is invoked right after the results are returned from the index, but
+   * before any post-filter predicates.
    *
-   * <p>The default implementation does not do anything. Should be overridden by implementation, if
-   * needed.
+   * <p>The filter is invoked before any other index predicates. If the filter returns 'true', then
+   * other index predicates are evaluated. Otherwise, the result from the index is not returned to
+   * the DataSource.
    */
-  default IndexRewriter<V> getIndexRewriter() {
-    return (in, opts) -> in;
+  default Optional<Matchable<V>> getIndexFilter() {
+    return Optional.empty();
   }
 }
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
index c21f32e..2141bf2 100644
--- a/java/com/google/gerrit/index/IndexConfig.java
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -38,6 +38,7 @@
 
   public static Builder fromConfig(Config cfg) {
     Builder b = builder();
+    setIfPresent(cfg, "defaultLimit", b::defaultLimit);
     setIfPresent(cfg, "maxLimit", b::maxLimit);
     setIfPresent(cfg, "maxPages", b::maxPages);
     setIfPresent(cfg, "maxTerms", b::maxTerms);
@@ -67,6 +68,7 @@
 
   public static Builder builder() {
     return new AutoValue_IndexConfig.Builder()
+        .defaultLimit(Integer.MAX_VALUE)
         .maxLimit(Integer.MAX_VALUE)
         .maxPages(Integer.MAX_VALUE)
         .maxTerms(DEFAULT_MAX_TERMS)
@@ -79,6 +81,10 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    public abstract Builder defaultLimit(int defaultLimit);
+
+    public abstract int defaultLimit();
+
     public abstract Builder maxLimit(int maxLimit);
 
     public abstract int maxLimit();
@@ -107,6 +113,7 @@
 
     public IndexConfig build() {
       IndexConfig cfg = autoBuild();
+      checkLimit(cfg.defaultLimit(), "defaultLimit");
       checkLimit(cfg.maxLimit(), "maxLimit");
       checkLimit(cfg.maxPages(), "maxPages");
       checkLimit(cfg.maxTerms(), "maxTerms");
@@ -121,6 +128,12 @@
   }
 
   /**
+   * Returns default limit for index queries, if the user does not provide one. If this is not set,
+   * then the max permitted limit for each user is used, which might be much higher than intended.
+   */
+  public abstract int defaultLimit();
+
+  /**
    * Returns maximum limit supported by the underlying index, or limited for performance reasons.
    */
   public abstract int maxLimit();
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index ff55546..29c920b 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -44,6 +44,9 @@
   public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
       NAME_FIELD.exact("name");
 
+  public static final IndexedField<ProjectData, String>.SearchSpec PREFIX_NAME_SPEC =
+      NAME_FIELD.prefix("nameprefix");
+
   public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
       IndexedField.<ProjectData>stringBuilder("Description")
           .stored()
@@ -59,6 +62,13 @@
   public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
       PARENT_NAME_FIELD.exact("parent_name");
 
+  public static final IndexedField<ProjectData, String> PARENT_NAME_2_FIELD =
+      IndexedField.<ProjectData>stringBuilder("ParentName2")
+          .build(p -> p.getParent().map(parent -> parent.getProject().getName()).orElse(null));
+
+  public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_2_SPEC =
+      PARENT_NAME_2_FIELD.exact("parent_name2");
+
   public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
       IndexedField.<ProjectData>iterableStringBuilder("NamePart")
           .size(200)
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index dac1012..6cd43db 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -65,7 +65,21 @@
   @Deprecated static final Schema<ProjectData> V5 = schema(V4);
 
   // Upgrade Lucene to 8.x requires reindexing.
-  static final Schema<ProjectData> V6 = schema(V5);
+  @Deprecated static final Schema<ProjectData> V6 = schema(V5);
+
+  @Deprecated
+  static final Schema<ProjectData> V7 =
+      new Schema.Builder<ProjectData>()
+          .add(V6)
+          .addIndexedFields(ProjectField.PARENT_NAME_2_FIELD)
+          .addSearchSpecs(ProjectField.PARENT_NAME_2_SPEC)
+          .build();
+
+  static final Schema<ProjectData> V8 =
+      new Schema.Builder<ProjectData>()
+          .add(V7)
+          .addSearchSpecs(ProjectField.PREFIX_NAME_SPEC)
+          .build();
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java b/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java
new file mode 100644
index 0000000..fb6e5ae
--- /dev/null
+++ b/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.project;
+
+import com.google.gerrit.index.query.PostFilterPredicate;
+import java.util.Locale;
+
+/** Predicate to match projects by a substring in the project name. */
+public class ProjectSubstringPredicate extends PostFilterPredicate<ProjectData> {
+
+  public ProjectSubstringPredicate(String fieldName, String value) {
+    super(fieldName, value);
+  }
+
+  @Override
+  public boolean match(ProjectData projectData) {
+    return projectData
+        .getProject()
+        .getName()
+        .toLowerCase(Locale.US)
+        .contains(getValue().toLowerCase(Locale.US));
+  }
+
+  @Override
+  public int getCost() {
+    return 2;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index e251b00..fc9bc00 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -194,7 +194,7 @@
   @Override
   public abstract boolean equals(Object other);
 
-  private static class Any<T> extends Predicate<T> implements Matchable<T> {
+  public static class Any<T> extends Predicate<T> implements Matchable<T> {
     private static final Any<Object> INSTANCE = new Any<>();
 
     private Any() {}
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 5440766..c1d92b0 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -274,8 +274,7 @@
                 getRequestedFields());
         logger.atFine().log("Query options: %s", opts);
         // Apply index-specific rewrite first
-        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
-        pred = rewriter.rewrite(pred, opts);
+        Predicate<T> pred = rewriter.rewrite(q, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
@@ -415,6 +414,8 @@
     possibleLimits.add(getPermittedLimit());
     if (userProvidedLimit > 0) {
       possibleLimits.add(userProvidedLimit);
+    } else if (indexConfig.defaultLimit() > 0) {
+      possibleLimits.add(indexConfig.defaultLimit());
     }
     if (limitField != null) {
       Integer limitFromPredicate = LimitPredicate.getLimit(limitField, p);
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 14ad528..4ff41a1 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -79,6 +79,8 @@
       return or(p);
     } else if (p instanceof NotPredicate) {
       return not(p);
+    } else if (p instanceof Predicate.Any) {
+      return new MatchAllDocsQuery();
     } else if (p instanceof IndexPredicate) {
       return fieldQuery((IndexPredicate<V>) p);
     } else if (p instanceof PostFilterPredicate) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 6e7ac6b..3587342 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -101,6 +101,7 @@
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
+        "//lib:roaringbitmap",
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 36d7888..d45d329 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -468,9 +468,7 @@
 
   public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
-    String name = ua.fullName();
     String email = ua.preferredEmail();
-
     if (email == null || email.isEmpty()) {
       // No preferred email is configured. Use a generic identity so we
       // don't leak an address the user may have given us, but doesn't
@@ -491,19 +489,18 @@
 
       email = user + "@" + host;
     }
-
-    if (name == null || name.isEmpty()) {
-      final int at = email.indexOf('@');
-      if (0 < at) {
-        name = email.substring(0, at);
-      } else {
-        name = anonymousCowardName;
-      }
-    }
-
+    String name = getCommitterName(ua, email);
     return new PersonIdent(name, email, when, zoneId);
   }
 
+  public Optional<PersonIdent> newCommitterIdent(String email, Instant when, ZoneId zoneId) {
+    if (!hasEmailAddress(email)) {
+      return Optional.empty();
+    }
+    String name = getCommitterName(getAccount(), email);
+    return Optional.of(new PersonIdent(name, email, when, zoneId));
+  }
+
   @Override
   public String toString() {
     return "IdentifiedUser[account " + getAccountId() + "]";
@@ -551,4 +548,17 @@
   public boolean hasSameAccountId(CurrentUser other) {
     return getAccountId().get() == other.getAccountId().get();
   }
+
+  protected String getCommitterName(Account ua, String email) {
+    String name = ua.fullName();
+    if (name == null || name.isEmpty()) {
+      final int at = email.indexOf('@');
+      if (0 < at) {
+        name = email.substring(0, at);
+      } else {
+        name = anonymousCowardName;
+      }
+    }
+    return name;
+  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 63ddd4e..fea48ae 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import java.io.IOException;
@@ -28,47 +25,6 @@
 import org.eclipse.jgit.lib.Repository;
 
 public interface StarredChangesUtil {
-  @AutoValue
-  abstract class StarField {
-    private static final String SEPARATOR = ":";
-
-    @Nullable
-    public static StarField parse(String s) {
-      Integer id;
-      int p = s.indexOf(SEPARATOR);
-      if (p >= 0) {
-        id = Ints.tryParse(s.substring(0, p));
-      } else {
-        // NOTE: This code branch should not be removed. This code is used internally by Google and
-        // must not be changed without approval from a Google contributor. In
-        // 992877d06d3492f78a3b189eb5579ddb86b9f0da we accidentally changed index writing to write
-        // <account_id> instead of <account_id>:star. As some servers have picked that up and wrote
-        // index entries with the short format, we should keep support its parsing.
-        id = Ints.tryParse(s);
-      }
-      if (id == null) {
-        return null;
-      }
-      return create(Account.id(id));
-    }
-
-    public static StarField create(Account.Id accountId) {
-      return new AutoValue_StarredChangesUtil_StarField(accountId);
-    }
-
-    public abstract Account.Id accountId();
-
-    @Override
-    public final String toString() {
-      // NOTE: The ":star" addition is used internally by Google and must not be removed without
-      // approval from a Google contributor. This method is used for writing change index data.
-      // Historically, we supported different kinds of labels, which were stored in this
-      // format, with "star" being the only label in use. This label addition stayed in order to
-      // keep the index format consistent while removing the star-label support.
-      return accountId() + SEPARATOR + "star";
-    }
-  }
-
   boolean isStarred(Account.Id accountId, Change.Id changeId);
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 2020d2f..c267822 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -27,6 +27,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
@@ -585,6 +587,13 @@
           .addAll(nameOrEmailSearchers)
           .build();
 
+  private final ImmutableList<Searcher<?>> exactSearchers =
+      ImmutableList.<Searcher<?>>builder()
+          .add(new BySelf())
+          .add(new ByExactAccountId())
+          .add(new ByEmail())
+          .build();
+
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final Emails emails;
@@ -650,6 +659,17 @@
         input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
   }
 
+  /** Resolves accounts using exact searchers. Similar to the previous method. */
+  @UsedAt(Project.GOOGLE)
+  public Result resolveExact(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        exactSearchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
+  }
+
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
     return searchImpl(
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index f311b35..78cf811 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.ListProjects;
 import com.google.gerrit.server.restapi.project.ListProjects.FilterType;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -94,8 +93,7 @@
     };
   }
 
-  private SortedMap<String, ProjectInfo> list(ListRequest request)
-      throws RestApiException, PermissionBackendException {
+  private SortedMap<String, ProjectInfo> list(ListRequest request) throws Exception {
     ListProjects lp = listProvider.get();
     lp.setShowDescription(request.getDescription());
     lp.setLimit(request.getLimit());
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 0f54381..716295f 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
@@ -117,6 +118,7 @@
   private final EmailFactories emailFactories;
   private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
+  private final TopicValidator topicValidator;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
@@ -170,6 +172,7 @@
       EmailFactories emailFactories,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
+      TopicValidator topicValidator,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       ReviewerModifier reviewerModifier,
@@ -188,6 +191,7 @@
     this.emailFactories = emailFactories;
     this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
+    this.topicValidator = topicValidator;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
@@ -469,7 +473,7 @@
     update.setSubjectForCommit("Create change");
     update.setBranch(change.getDest().branch());
     try {
-      update.setTopic(change.getTopic());
+      update.setTopic(change.getTopic(), topicValidator);
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
diff --git a/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
index 04bc6e4..55b4d74 100644
--- a/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
+++ b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import java.util.Map;
+import java.util.Set;
 
 public class CustomKeyedValuesUtil {
   public static class InvalidCustomKeyedValueException extends Exception {
@@ -35,7 +36,7 @@
     }
   }
 
-  static ImmutableMap<String, String> extractCustomKeyedValues(ImmutableMap<String, String> input)
+  static ImmutableMap<String, String> extractCustomKeyedValues(Map<String, String> input)
       throws InvalidCustomKeyedValueException {
     if (input == null) {
       return ImmutableMap.of();
@@ -57,7 +58,7 @@
     return builder.build();
   }
 
-  static ImmutableSet<String> extractCustomKeys(ImmutableSet<String> input)
+  static ImmutableSet<String> extractCustomKeys(Set<String> input)
       throws InvalidCustomKeyedValueException {
     if (input == null) {
       return ImmutableSet.of();
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index c54b902..4c0eb69 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.project.ProjectState;
@@ -39,6 +40,7 @@
 import java.util.zip.ZipOutputStream;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
@@ -60,11 +62,15 @@
 
   private final GitRepositoryManager repoManager;
   private final FileTypeRegistry registry;
+  private final long maxFileSizeBytes;
 
   @Inject
-  FileContentUtil(GitRepositoryManager repoManager, FileTypeRegistry ftr) {
+  FileContentUtil(
+      GitRepositoryManager repoManager, FileTypeRegistry ftr, @GerritServerConfig Config config) {
     this.repoManager = repoManager;
     this.registry = ftr;
+    long maxFileSizeDownload = config.getLong("change", null, "maxFileSizeDownload", 0);
+    this.maxFileSizeBytes = maxFileSizeDownload > 0 ? maxFileSizeDownload : Long.MAX_VALUE;
   }
 
   /**
@@ -117,6 +123,7 @@
         }
 
         ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        checkMaxFileSizeBytes(obj);
         byte[] raw;
         try {
           raw = obj.getCachedBytes(MAX_SIZE);
@@ -154,7 +161,7 @@
 
   public BinaryResult downloadContent(
       ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, BadRequestException {
     try (Repository repo = openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       String suffix = "new";
@@ -179,6 +186,8 @@
 
         ObjectId id = tw.getObjectId(0);
         ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        checkMaxFileSizeBytes(obj);
+
         byte[] raw;
         try {
           raw = obj.getCachedBytes(MAX_SIZE);
@@ -194,6 +203,16 @@
     }
   }
 
+  private void checkMaxFileSizeBytes(ObjectLoader obj) throws BadRequestException {
+    if (obj.getSize() > this.maxFileSizeBytes) {
+      throw new BadRequestException(
+          String.format(
+              "File too big. File size: %d bytes. Configured 'maxFileSizeDownload' limit: %d"
+                  + " bytes.",
+              obj.getSize(), this.maxFileSizeBytes));
+    }
+  }
+
   private BinaryResult wrapBlob(
       String path,
       final ObjectLoader obj,
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 4a09f84..854fd4e 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
@@ -84,6 +85,7 @@
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
   private final AutoMerger autoMerger;
+  private final TopicValidator topicValidator;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -132,6 +134,7 @@
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
       AutoMerger autoMerger,
+      TopicValidator topicValidator,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -147,6 +150,7 @@
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
     this.autoMerger = autoMerger;
+    this.topicValidator = topicValidator;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -307,7 +311,7 @@
     if (topic != null) {
       change.setTopic(topic);
       try {
-        update.setTopic(topic);
+        update.setTopic(topic, topicValidator);
       } catch (ValidationException ex) {
         throw new BadRequestException(ex.getMessage());
       }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 540e438..f46196f 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -58,6 +58,7 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.diff.Sequence;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -504,7 +505,11 @@
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
-      cb.setCommitter(ctx.newCommitterIdent());
+      PersonIdent committerIdent =
+          Optional.ofNullable(original.getCommitterIdent())
+              .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), ctx.getIdentifiedUser()))
+              .orElseGet(ctx::newCommitterIdent);
+      cb.setCommitter(committerIdent);
     }
     if (matchAuthorToCommitterDate) {
       cb.setAuthor(
diff --git a/java/com/google/gerrit/server/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
index ee35d1d..58342e5 100644
--- a/java/com/google/gerrit/server/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -36,6 +37,7 @@
   private final String topic;
   private final TopicEdited topicEdited;
   private final ChangeMessagesUtil cmUtil;
+  private final TopicValidator topicValidator;
 
   private Change change;
   private String oldTopicName;
@@ -43,10 +45,14 @@
 
   @Inject
   public SetTopicOp(
-      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Nullable @Assisted String topic) {
+      TopicEdited topicEdited,
+      ChangeMessagesUtil cmUtil,
+      @Nullable @Assisted String topic,
+      TopicValidator topicValidator) {
     this.topic = topic;
     this.topicEdited = topicEdited;
     this.cmUtil = cmUtil;
+    this.topicValidator = topicValidator;
   }
 
   @Override
@@ -69,7 +75,7 @@
     }
     change.setTopic(Strings.emptyToNull(newTopicName));
     try {
-      update.setTopic(change.getTopic());
+      update.setTopic(change.getTopic(), topicValidator);
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 4fdbd4a..28baa1a 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -19,7 +19,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.CoreDownloadSchemes;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.server.change.ArchiveFormatInternal;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -41,6 +40,16 @@
  */
 @Singleton
 public class DownloadConfig {
+  /** Preferred method to download a change. */
+  public enum DownloadCommand {
+    PULL,
+    CHECKOUT,
+    CHERRY_PICK,
+    FORMAT_PATCH,
+    BRANCH,
+    RESET,
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ImmutableSet<String> downloadSchemes;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 0fb9310..17d6212 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -177,9 +177,7 @@
 import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
-import com.google.gerrit.server.patch.DiffFileSizeValidator;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
-import com.google.gerrit.server.patch.DiffValidator;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
 import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
@@ -202,6 +200,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.quota.QuotaEnforcer;
+import com.google.gerrit.server.restapi.RestModule;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
@@ -223,6 +222,7 @@
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
+import com.google.gerrit.server.validators.CustomKeyedValueValidationListener;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
@@ -289,6 +289,7 @@
     install(new DefaultSubmitRuleModule());
     install(new IgnoreSelfApprovalRuleModule());
     install(new ReceiveCommitsModule());
+    install(new RestModule());
     install(new SshAddressesModule());
     install(new FileInfoJsonModule());
     install(ThreadLocalRequestContext.module());
@@ -407,8 +408,6 @@
         .to(SubmitRequirementConfigValidator.class);
     DynamicSet.bind(binder(), CommitValidationListener.class).to(PrologRulesWarningValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
-    DynamicSet.setOf(binder(), DiffValidator.class);
-    DynamicSet.bind(binder(), DiffValidator.class).to(DiffFileSizeValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
     DynamicSet.setOf(binder(), OnSubmitValidationListener.class);
@@ -418,6 +417,7 @@
     DynamicSet.setOf(binder(), HashtagValidationListener.class);
     DynamicSet.setOf(binder(), OutgoingEmailValidationListener.class);
     DynamicSet.setOf(binder(), AccountActivationValidationListener.class);
+    DynamicSet.setOf(binder(), CustomKeyedValueValidationListener.class);
     DynamicItem.itemOf(binder(), AvatarProvider.class);
     DynamicSet.setOf(binder(), LifecycleListener.class);
     DynamicSet.setOf(binder(), TopMenu.class);
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 4c15a7e..68569f0 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -535,7 +535,7 @@
       builder.setTreeId(tree);
       builder.setParentIds(basePatchsetCommit.getParents());
       builder.setAuthor(basePatchsetCommit.getAuthorIdent());
-      builder.setCommitter(getCommitterIdent(timestamp));
+      builder.setCommitter(getCommitterIdent(basePatchsetCommit, timestamp));
       builder.setMessage(commitMessage);
       ObjectId newCommitId = objectInserter.insert(builder);
       objectInserter.flush();
@@ -543,9 +543,14 @@
     }
   }
 
-  private PersonIdent getCommitterIdent(Instant commitTimestamp) {
+  private PersonIdent getCommitterIdent(RevCommit basePatchsetCommit, Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, zoneId);
+    return Optional.ofNullable(basePatchsetCommit.getCommitterIdent())
+        .map(
+            ident ->
+                user.newCommitterIdent(ident.getEmailAddress(), commitTimestamp, zoneId)
+                    .orElseGet(() -> user.newCommitterIdent(commitTimestamp, zoneId)))
+        .orElseGet(() -> user.newCommitterIdent(commitTimestamp, zoneId));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/git/LargeObjectException.java b/java/com/google/gerrit/server/git/LargeObjectException.java
index 145b631..04db42c 100644
--- a/java/com/google/gerrit/server/git/LargeObjectException.java
+++ b/java/com/google/gerrit/server/git/LargeObjectException.java
@@ -25,10 +25,6 @@
 
   private static final long serialVersionUID = 1L;
 
-  public LargeObjectException(String message) {
-    super(message);
-  }
-
   public LargeObjectException(String message, org.eclipse.jgit.errors.LargeObjectException cause) {
     super(message, cause);
   }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index a522a2f..73aec64 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -286,7 +286,6 @@
     return commit;
   }
 
-  @SuppressWarnings("resource") // TemporaryBuffer requires calling close before reading.
   public static ObjectId mergeWithConflicts(
       RevWalk rw,
       ObjectInserter ins,
@@ -297,6 +296,21 @@
       RevCommit theirs,
       Map<String, MergeResult<? extends Sequence>> mergeResults)
       throws IOException {
+    return mergeWithConflicts(rw, ins, dc, oursName, ours, theirsName, theirs, mergeResults, false);
+  }
+
+  @SuppressWarnings("resource") // TemporaryBuffer requires calling close before reading.
+  public static ObjectId mergeWithConflicts(
+      RevWalk rw,
+      ObjectInserter ins,
+      DirCache dc,
+      String oursName,
+      RevCommit ours,
+      String theirsName,
+      RevCommit theirs,
+      Map<String, MergeResult<? extends Sequence>> mergeResults,
+      boolean diff3Format)
+      throws IOException {
     rw.parseBody(ours);
     rw.parseBody(theirs);
     String oursMsg = ours.getShortMessage();
@@ -324,7 +338,11 @@
       try {
         // TODO(dborowitz): Respect inCoreLimit here.
         buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024);
-        fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+        if (diff3Format) {
+          fmt.formatMergeDiff3(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+        } else {
+          fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+        }
         buf.close(); // Flush file and close for writes, but leave available for reading.
 
         try (InputStream in = buf.openInputStream()) {
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index daf5ea5..d417089 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -34,7 +34,7 @@
       @Override
       protected void configure() {
         persist(CACHE_NAME, String.class, TagSetHolder.class)
-            .version(1)
+            .version(2)
             .keySerializer(StringCacheSerializer.INSTANCE)
             .valueSerializer(TagSetHolder.Serializer.INSTANCE);
         bind(TagCache.class);
diff --git a/java/com/google/gerrit/server/git/TagMatcher.java b/java/com/google/gerrit/server/git/TagMatcher.java
index f003b6f..6edc7f0 100644
--- a/java/com/google/gerrit/server/git/TagMatcher.java
+++ b/java/com/google/gerrit/server/git/TagMatcher.java
@@ -17,15 +17,15 @@
 import com.google.gerrit.server.git.TagSet.Tag;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.BitSet;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.roaringbitmap.RoaringBitmap;
 
 public class TagMatcher {
-  final BitSet mask = new BitSet();
+  final RoaringBitmap mask = new RoaringBitmap();
   final List<Ref> newRefs = new ArrayList<>();
   final List<LostRef> lostRefs = new ArrayList<>();
   final TagSetHolder holder;
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 57fcf71..a528c8f 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -26,8 +26,9 @@
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.protobuf.ByteString;
+import java.io.DataOutputStream;
 import java.io.IOException;
-import java.util.BitSet;
+import java.nio.ByteBuffer;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
@@ -42,6 +43,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.roaringbitmap.RoaringBitmap;
 
 /**
  * Builds a set of tags, and tracks which tags are reachable from which non-tag, non-special refs.
@@ -67,7 +69,7 @@
 
   /**
    * refName => ref. CachedRef is a ref that has an integer identity, used for indexing into
-   * BitSets.
+   * RoaringBitmaps.
    */
   private final Map<String, CachedRef> refs;
 
@@ -145,7 +147,7 @@
         // The reference has not been moved. It can be used as-is.
         ObjectId savedObjectId = savedRef.get();
         if (currentRef.getObjectId().equals(savedObjectId)) {
-          m.mask.set(savedRef.flag);
+          m.mask.add(savedRef.flag);
           continue;
         }
 
@@ -162,7 +164,7 @@
           if (rw.isMergedInto(savedCommit, currentCommit)) {
             // Fast-forward. Safely update the reference in-place.
             savedRef.compareAndSet(savedObjectId, currentRef.getObjectId());
-            m.mask.set(savedRef.flag);
+            m.mask.add(savedRef.flag);
             continue;
           }
 
@@ -176,7 +178,7 @@
           RevCommit c;
           while ((c = rw.next()) != null) {
             Tag tag = tags.get(c);
-            if (tag != null && tag.refFlags.get(savedRef.flag)) {
+            if (tag != null && tag.refFlags.contains(savedRef.flag)) {
               m.lostRefs.add(new TagMatcher.LostRef(tag, savedRef.flag));
               err = true;
             }
@@ -184,7 +186,7 @@
           if (!err) {
             // All of the tags are still reachable. Update in-place.
             savedRef.compareAndSet(savedObjectId, currentRef.getObjectId());
-            m.mask.set(savedRef.flag);
+            m.mask.add(savedRef.flag);
           }
 
         } catch (IOException err) {
@@ -233,7 +235,7 @@
       // underlying bit set.
       TagCommit c;
       while ((c = (TagCommit) rw.next()) != null) {
-        BitSet mine = c.refFlags;
+        RoaringBitmap mine = c.refFlags;
         int pCnt = c.getParentCount();
         for (int pIdx = 0; pIdx < pCnt; pIdx++) {
           ((TagCommit) c.getParent(pIdx)).refFlags.or(mine);
@@ -257,11 +259,16 @@
     proto
         .getTagList()
         .forEach(
-            t ->
-                tags.add(
-                    new Tag(
-                        idConverter.fromByteString(t.getId()),
-                        BitSet.valueOf(t.getFlags().asReadOnlyByteBuffer()))));
+            t -> {
+              RoaringBitmap flags = new RoaringBitmap();
+              ByteBuffer in = ByteBuffer.wrap(t.getFlags().toByteArray());
+              try {
+                flags.deserialize(in);
+              } catch (IOException e) {
+                logger.atSevere().withCause(e).log();
+              }
+              tags.add(new Tag(idConverter.fromByteString(t.getId()), flags));
+            });
     return new TagSet(Project.nameKey(proto.getProjectName()), refs, tags);
   }
 
@@ -277,12 +284,20 @@
                     .setFlag(cr.flag)
                     .build()));
     tags.forEach(
-        t ->
-            b.addTag(
-                TagProto.newBuilder()
-                    .setId(idConverter.toByteString(t))
-                    .setFlags(ByteString.copyFrom(t.refFlags.toByteArray()))
-                    .build()));
+        t -> {
+          t.refFlags.runOptimize();
+          ByteString.Output out = ByteString.newOutput(t.refFlags.serializedSizeInBytes());
+          try {
+            t.refFlags.serialize(new DataOutputStream(out));
+          } catch (IOException e) {
+            logger.atSevere().withCause(e).log();
+          }
+          b.addTag(
+              TagProto.newBuilder()
+                  .setId(idConverter.toByteString(t))
+                  .setFlags(out.toByteString())
+                  .build());
+        });
     return b.build();
   }
 
@@ -328,8 +343,8 @@
       refs.put(newRef.getName(), new CachedRef(newRef, newFlag));
 
       for (Tag tag : tags) {
-        if (tag.refFlags.get(srcFlag)) {
-          tag.refFlags.set(newFlag);
+        if (tag.refFlags.contains(srcFlag)) {
+          tag.refFlags.add(newFlag);
         }
       }
     }
@@ -341,7 +356,7 @@
     refs.putAll(old.refs);
 
     for (Tag srcTag : old.tags) {
-      BitSet mine = new BitSet();
+      RoaringBitmap mine = new RoaringBitmap();
       mine.or(srcTag.refFlags);
       tags.add(new Tag(srcTag, mine));
     }
@@ -349,7 +364,7 @@
     for (TagMatcher.LostRef lost : m.lostRefs) {
       Tag mine = tags.get(lost.tag);
       if (mine != null) {
-        mine.refFlags.clear(lost.flag);
+        mine.refFlags.remove(lost.flag);
       }
     }
   }
@@ -361,14 +376,14 @@
     }
 
     if (!tags.contains(id)) {
-      BitSet flags;
+      RoaringBitmap flags;
       try {
         flags = ((TagCommit) rw.parseCommit(id)).refFlags;
       } catch (IncorrectObjectTypeException notCommit) {
-        flags = new BitSet();
+        flags = new RoaringBitmap();
       } catch (IOException e) {
         logger.atWarning().withCause(e).log("Error on %s of %s", ref.getName(), projectName);
-        flags = new BitSet();
+        flags = new RoaringBitmap();
       }
       tags.add(new Tag(id, flags));
     }
@@ -380,7 +395,7 @@
       rw.markStart(commit);
 
       int flag = refs.size();
-      commit.refFlags.set(flag);
+      commit.refFlags.add(flag);
       refs.put(ref.getName(), new CachedRef(ref, flag));
     } catch (IncorrectObjectTypeException notCommit) {
       // No need to spam the logs.
@@ -414,16 +429,16 @@
   static final class Tag extends ObjectIdOwnerMap.Entry {
 
     // a RefCache.flag => isVisible map. This reference is aliased to the
-    // bitset in TagCommit.refFlags.
-    @VisibleForTesting final BitSet refFlags;
+    // RoaringBitmap in TagCommit.refFlags.
+    @VisibleForTesting final RoaringBitmap refFlags;
 
-    Tag(AnyObjectId id, BitSet flags) {
+    Tag(AnyObjectId id, RoaringBitmap flags) {
       super(id);
       this.refFlags = flags;
     }
 
-    boolean has(BitSet mask) {
-      return refFlags.intersects(mask);
+    boolean has(RoaringBitmap mask) {
+      return RoaringBitmap.intersects(refFlags, mask);
     }
 
     @Override
@@ -431,7 +446,7 @@
       return MoreObjects.toStringHelper(this).addValue(name()).add("refFlags", refFlags).toString();
     }
   }
-  /** A ref along with its index into BitSet. */
+  /** A ref along with its index into RoaringBitmap. */
   @VisibleForTesting
   static final class CachedRef extends AtomicReference<ObjectId> {
     private static final long serialVersionUID = 1L;
@@ -472,11 +487,11 @@
   // TODO(hanwen): this would be better named as CommitWithReachability, as it also holds non-tags.
   private static final class TagCommit extends RevCommit {
     /** CachedRef.flag => isVisible, indicating if this commit is reachable from the ref. */
-    final BitSet refFlags;
+    final RoaringBitmap refFlags;
 
     TagCommit(AnyObjectId id) {
       super(id);
-      refFlags = new BitSet();
+      refFlags = new RoaringBitmap();
     }
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index d17fb68..7cc843b 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.validators.TopicValidator;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -133,6 +134,7 @@
   private final ProjectCache projectCache;
   private final ReviewerModifier reviewerModifier;
   private final ChangeUtil changeUtil;
+  private final TopicValidator topicValidator;
 
   private final ProjectState projectState;
   private final Change change;
@@ -178,6 +180,7 @@
       EmailNewPatchSet.Factory emailNewPatchSetFactory,
       ReviewerModifier reviewerModifier,
       ChangeUtil changeUtil,
+      TopicValidator topicValidator,
       @Assisted ProjectState projectState,
       @Assisted Change change,
       @Assisted boolean checkMergedInto,
@@ -206,6 +209,7 @@
     this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.reviewerModifier = reviewerModifier;
     this.changeUtil = changeUtil;
+    this.topicValidator = topicValidator;
 
     this.projectState = projectState;
     this.change = change;
@@ -285,7 +289,7 @@
       }
       if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
         try {
-          update.setTopic(magicBranch.topic);
+          update.setTopic(magicBranch.topic, topicValidator);
         } catch (ValidationException ex) {
           throw new BadRequestException(ex.getMessage());
         }
diff --git a/java/com/google/gerrit/server/git/validators/TopicValidator.java b/java/com/google/gerrit/server/git/validators/TopicValidator.java
new file mode 100644
index 0000000..46c56f3
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/TopicValidator.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.validators;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+/** Validator for topic changes. */
+@Singleton
+public class TopicValidator {
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final int topicLimit;
+
+  @Inject
+  TopicValidator(
+      @GerritServerConfig Config serverConfig, Provider<InternalChangeQuery> queryProvider) {
+    this.queryProvider = queryProvider;
+    int configuredLimit = serverConfig.getInt("change", "topicLimit", 5_000);
+    this.topicLimit = configuredLimit > 0 ? configuredLimit : Integer.MAX_VALUE;
+  }
+
+  public void validateSize(@Nullable String topic) throws ValidationException {
+    if (Strings.isNullOrEmpty(topic)) {
+      return;
+    }
+    int topicSize = queryProvider.get().noFields().byTopicOpen(topic).size();
+    if (topicSize >= topicLimit) {
+      throw new ValidationException(
+          String.format(
+              "Topic '%s' already contains maximum number of allowed changes per 'topicLimit'"
+                  + " server config value %d.",
+              topic, topicLimit));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 03388e4..d9d7a90 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -23,6 +23,7 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
@@ -35,6 +36,7 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
+import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
 import com.google.common.reflect.TypeToken;
 import com.google.gerrit.common.Nullable;
@@ -62,7 +64,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
@@ -1274,12 +1275,11 @@
           .build(
               cd ->
                   Iterables.transform(
-                      cd.stars(),
-                      accountId -> StarredChangesUtil.StarField.create(accountId).toString()),
+                      cd.stars(), accountId -> StarField.create(accountId).toString()),
               (cd, field) ->
                   cd.setStars(
                       StreamSupport.stream(field.spliterator(), false)
-                          .map(f -> StarredChangesUtil.StarField.parse(f).accountId())
+                          .map(f -> StarField.parse(f).accountId())
                           .collect(toImmutableList())));
 
   public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec STAR_SPEC =
@@ -1815,4 +1815,45 @@
     }
     return str;
   }
+
+  @AutoValue
+  abstract static class StarField {
+    private static final String SEPARATOR = ":";
+
+    @Nullable
+    static StarField parse(String s) {
+      Integer id;
+      int p = s.indexOf(SEPARATOR);
+      if (p >= 0) {
+        id = Ints.tryParse(s.substring(0, p));
+      } else {
+        // NOTE: This code branch should not be removed. This code is used internally by Google and
+        // must not be changed without approval from a Google contributor. In
+        // 992877d06d3492f78a3b189eb5579ddb86b9f0da we accidentally changed index writing to write
+        // <account_id> instead of <account_id>:star. As some servers have picked that up and wrote
+        // index entries with the short format, we should keep support its parsing.
+        id = Ints.tryParse(s);
+      }
+      if (id == null) {
+        return null;
+      }
+      return create(Account.id(id));
+    }
+
+    static StarField create(Account.Id accountId) {
+      return new AutoValue_ChangeField_StarField(accountId);
+    }
+
+    public abstract Account.Id accountId();
+
+    @Override
+    public final String toString() {
+      // NOTE: The ":star" addition is used internally by Google and must not be removed without
+      // approval from a Google contributor. This method is used for writing change index data.
+      // Historically, we supported different kinds of labels, which were stored in this
+      // format, with "star" being the only label in use. This label addition stayed in order to
+      // keep the index format consistent while removing the star-label support.
+      return accountId() + SEPARATOR + "star";
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 8f5e36e..d2c3820 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -157,6 +157,12 @@
 
   @Override
   public boolean match(ChangeData cd) {
+    if (index.getIndexFilter().isPresent()) {
+      // Evaluate the filter. If we pass the filter, then evaluate everything else.
+      if (!index.getIndexFilter().get().match(cd)) {
+        return false;
+      }
+    }
     Predicate<ChangeData> pred = getChild(0);
     if (source != null && fromSource.get(cd) == source && postIndexMatch(pred, cd)) {
       return true;
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
index 3ec2b35..a1eede0 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
@@ -19,8 +19,8 @@
 
 /** Send notice about a change being abandoned by its owner. */
 public class AbandonedChangeEmailDecorator implements ChangeEmail.ChangeEmailDecorator {
-  private ChangeEmail changeEmail;
-  private OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+  protected OutgoingEmail email;
 
   @Override
   public void init(OutgoingEmail email, ChangeEmail changeEmail) {
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
index 2db75e8..6d09a2b 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
@@ -18,14 +18,13 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 
 /** Base class for Attention Set email senders */
-public final class AttentionSetChangeEmailDecoratorImpl
-    implements AttentionSetChangeEmailDecorator {
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
+public class AttentionSetChangeEmailDecoratorImpl implements AttentionSetChangeEmailDecorator {
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
 
-  private Account.Id attentionSetUser;
-  private String reason;
-  private AttentionSetChange attentionSetChange;
+  protected Account.Id attentionSetUser;
+  protected String reason;
+  protected AttentionSetChange attentionSetChange;
 
   @Override
   public void setAttentionSetUser(Account.Id attentionSetUser) {
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index ea31fc0..b15a506 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -109,6 +109,12 @@
   /** Get the patch list corresponding to this patch set. */
   Map<String, FileDiffOutput> listModifiedFiles();
 
+  /** Get the number of added lines in a change. */
+  int getInsertionsCount();
+
+  /** Get the number of deleted lines in a change. */
+  int getDeletionsCount();
+
   /** Get the project entity the change is in; null if its been deleted. */
   ProjectState getProjectState();
 
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
index bd278fe..4ecbd52 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -75,22 +75,22 @@
 
 /** Populates an email for change related notifications. */
 @AutoFactory
-public final class ChangeEmailImpl implements ChangeEmail {
+public class ChangeEmailImpl implements ChangeEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   // Available after construction
-  private final EmailArguments args;
-  private final Set<Account.Id> currentAttentionSet;
-  private final Change change;
-  private final ChangeData changeData;
-  private final BranchNameKey branch;
-  private final ChangeEmailDecorator changeEmailDecorator;
+  protected final EmailArguments args;
+  protected final Set<Account.Id> currentAttentionSet;
+  protected final Change change;
+  protected final ChangeData changeData;
+  protected final BranchNameKey branch;
+  protected final ChangeEmailDecorator changeEmailDecorator;
 
   // Available after init or after being explicitly set.
-  private OutgoingEmail email;
+  protected OutgoingEmail email;
   private List<Account.Id> stars;
-  private PatchSet patchSet;
-  private PatchSetInfo patchSetInfo;
+  protected PatchSet patchSet;
+  protected PatchSetInfo patchSetInfo;
   private String changeMessage;
   private String changeMessageThreadId;
   private Instant timestamp;
@@ -248,11 +248,12 @@
     }
   }
 
-  private void setChangeSubjectHeader() {
+  protected void setChangeSubjectHeader() {
     email.setHeader(FieldName.SUBJECT, email.textTemplate("ChangeSubject"));
   }
 
-  private int getInsertionsCount() {
+  @Override
+  public int getInsertionsCount() {
     return listModifiedFiles().entrySet().stream()
         .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
         .map(Map.Entry::getValue)
@@ -260,7 +261,8 @@
         .reduce(0, Integer::sum);
   }
 
-  private int getDeletionsCount() {
+  @Override
+  public int getDeletionsCount() {
     return listModifiedFiles().values().stream()
         .map(FileDiffOutput::deletions)
         .reduce(0, Integer::sum);
@@ -272,7 +274,7 @@
    * clickthroughs where the link came from.
    */
   @Nullable
-  private String getChangeUrl() {
+  protected String getChangeUrl() {
     return args.urlFormatter
         .get()
         .getChangeViewUrl(change.getProject(), change.getId())
@@ -281,7 +283,7 @@
   }
 
   /** Sets headers for conversation grouping */
-  private void setThreadHeaders() {
+  protected void setThreadHeaders() {
     if (isThreadReply) {
       email.setHeader("In-Reply-To", changeMessageThreadId);
     }
@@ -298,7 +300,7 @@
   }
 
   /** Create the change message and the affected file list. */
-  private String getChangeDetail() {
+  protected String getChangeDetail() {
     try {
       StringBuilder detail = new StringBuilder();
 
@@ -644,14 +646,14 @@
    * A shortened subject is the subject limited to 72 characters, with an ellipsis if it exceeds
    * that limit.
    */
-  private static String shortenSubject(String subject) {
+  protected static String shortenSubject(String subject) {
     if (subject.length() < 73) {
       return subject;
     }
     return subject.substring(0, 69) + "...";
   }
 
-  private Set<String> getEmailsByState(ReviewerStateInternal state) {
+  protected Set<String> getEmailsByState(ReviewerStateInternal state) {
     Set<String> reviewers = new TreeSet<>();
     try {
       for (Account.Id who : changeData.reviewers().byState(state)) {
@@ -676,7 +678,7 @@
     return attentionSet;
   }
 
-  private boolean getIncludeDiff() {
+  protected boolean getIncludeDiff() {
     return args.settings.includeDiff;
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecoratorImpl.java
index 58ff55f..8953318 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecoratorImpl.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecoratorImpl.java
@@ -27,11 +27,11 @@
 
 /** Let users know that a reviewer and possibly her review have been removed. */
 public class DeleteReviewerChangeEmailDecoratorImpl implements DeleteReviewerChangeEmailDecorator {
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
 
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
+  protected final Set<Account.Id> reviewers = new HashSet<>();
+  protected final Set<Address> reviewersByEmail = new HashSet<>();
 
   @Override
   public void addReviewers(Collection<Account.Id> cc) {
@@ -44,7 +44,7 @@
   }
 
   @Nullable
-  private List<String> getReviewerNames() {
+  protected List<String> getReviewerNames() {
     if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
index 873db91..9949a04 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
@@ -20,8 +20,8 @@
 
 /** Send notice about a vote that was removed from a change. */
 public class DeleteVoteChangeEmailDecorator implements ChangeEmailDecorator {
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
 
   @Override
   public void init(OutgoingEmail email, ChangeEmail changeEmail) {
diff --git a/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
index cc13ea2..937d7a8 100644
--- a/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
@@ -37,13 +38,14 @@
 public class MergedChangeEmailDecorator implements ChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
-  private LabelTypes labelTypes;
-  private final EmailArguments args;
-  private final Optional<String> stickyApprovalDiff;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+  protected LabelTypes labelTypes;
+  protected final EmailArguments args;
+  protected final Optional<String> stickyApprovalDiff;
 
-  MergedChangeEmailDecorator(@Provided EmailArguments args, Optional<String> stickyApprovalDiff) {
+  public MergedChangeEmailDecorator(
+      @Provided EmailArguments args, Optional<String> stickyApprovalDiff) {
     this.args = args;
     this.stickyApprovalDiff = stickyApprovalDiff;
   }
@@ -68,7 +70,7 @@
     }
   }
 
-  private String getApprovals() {
+  protected String getApprovals() {
     try {
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
@@ -132,7 +134,7 @@
   }
 
   @Override
-  public void populateEmailContent() {
+  public void populateEmailContent() throws EmailException {
     email.addSoyEmailDataParam("approvals", getApprovals());
     if (stickyApprovalDiff.isPresent()) {
       email.addSoyEmailDataParam("stickyApprovalDiff", stickyApprovalDiff.get());
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecoratorImpl.java
index 4a0fdb4..08b841b 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecoratorImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecoratorImpl.java
@@ -48,18 +48,19 @@
     implements ReplacePatchSetChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final EmailArguments args;
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-  private final ChangeKind changeKind;
-  private final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
-  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+  protected final EmailArguments args;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+  protected final Set<Account.Id> reviewers = new HashSet<>();
+  protected final Set<Account.Id> extraCC = new HashSet<>();
+  protected final ChangeKind changeKind;
+  protected final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
+  protected final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
       preUpdateSubmitRequirementResultsSupplier;
-  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
+  protected final Map<SubmitRequirement, SubmitRequirementResult>
+      postUpdateSubmitRequirementResults;
 
-  ReplacePatchSetChangeEmailDecoratorImpl(
+  public ReplacePatchSetChangeEmailDecoratorImpl(
       @Provided EmailArguments args,
       Project.NameKey project,
       Change.Id changeId,
@@ -123,7 +124,7 @@
   }
 
   @Nullable
-  private ImmutableList<String> getReviewerNames() {
+  protected ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       if (id.equals(email.getFrom())) {
@@ -137,7 +138,7 @@
     return names.stream().sorted().collect(toImmutableList());
   }
 
-  private ImmutableList<String> formatOutdatedApprovals() {
+  protected ImmutableList<String> formatOutdatedApprovals() {
     return outdatedApprovals.stream()
         .map(
             outdatedApproval ->
diff --git a/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
index 38eab48..fea7ecd 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
@@ -20,8 +20,8 @@
 
 /** Send notice about a change being restored by its owner. */
 public class RestoredChangeEmailDecorator implements ChangeEmailDecorator {
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
 
   @Override
   public void init(OutgoingEmail email, ChangeEmail changeEmail) {
diff --git a/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
index d1cff9c..4328843 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
@@ -20,8 +20,8 @@
 
 /** Send notice about a change being reverted. */
 public class RevertedChangeEmailDecorator implements ChangeEmailDecorator {
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
 
   @Override
   public void init(OutgoingEmail email, ChangeEmail changeEmail) {
diff --git a/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecoratorImpl.java
index 16fca48..23a6525 100644
--- a/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecoratorImpl.java
+++ b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecoratorImpl.java
@@ -27,16 +27,16 @@
 
 /** Sends an email alerting a user to a new change for them to review. */
 public class StartReviewChangeEmailDecoratorImpl implements StartReviewChangeEmailDecorator {
-  private OutgoingEmail email;
-  private ChangeEmail changeEmail;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
 
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-  private final Set<Address> extraCCByEmail = new HashSet<>();
-  private final Set<Account.Id> removedReviewers = new HashSet<>();
-  private final Set<Address> removedByEmailReviewers = new HashSet<>();
-  private boolean isCreateChange = false;
+  protected final Set<Account.Id> reviewers = new HashSet<>();
+  protected final Set<Address> reviewersByEmail = new HashSet<>();
+  protected final Set<Account.Id> extraCC = new HashSet<>();
+  protected final Set<Address> extraCCByEmail = new HashSet<>();
+  protected final Set<Account.Id> removedReviewers = new HashSet<>();
+  protected final Set<Address> removedByEmailReviewers = new HashSet<>();
+  protected boolean isCreateChange = false;
 
   @Override
   public void addReviewers(Collection<Account.Id> cc) {
@@ -80,7 +80,7 @@
   }
 
   @Nullable
-  private List<String> getReviewerNames() {
+  protected List<String> getReviewerNames() {
     if (reviewers.isEmpty()) {
       return null;
     }
@@ -92,7 +92,7 @@
   }
 
   @Nullable
-  private List<String> getRemovedReviewerNames() {
+  protected List<String> getRemovedReviewerNames() {
     if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 0fc03f3..23fc000 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -81,6 +81,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AttentionSetUtil;
@@ -446,8 +447,8 @@
     }
   }
 
-  public void setTopic(String topic) throws ValidationException {
-
+  public void setTopic(String topic, TopicValidator validator) throws ValidationException {
+    validator.validateSize(topic);
     if (isIllegalTopic(topic)) {
       throw new ValidationException("topic can't contain quotation marks.");
     }
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 0818f23..e27faf6 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -83,6 +83,10 @@
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
 
+  public static boolean diff3ConflictView(Config cfg) {
+    return cfg.getBoolean("change", null, "diff3ConflictView", false);
+  }
+
   private enum OperationType {
     CACHE_LOAD,
     IN_MEMORY_WRITE,
@@ -93,6 +97,7 @@
   private final Timer1<OperationType> latency;
   private final Provider<PersonIdent> gerritIdentProvider;
   private final boolean save;
+  private final boolean useDiff3;
   private final ThreeWayMergeStrategy configuredMergeStrategy;
 
   @Inject
@@ -117,6 +122,7 @@
                 .setUnit("milliseconds"),
             operationTypeField);
     this.save = cacheAutomerge(cfg);
+    this.useDiff3 = diff3ConflictView(cfg);
     this.gerritIdentProvider = gerritIdentProvider;
     this.configuredMergeStrategy = MergeUtil.getMergeStrategy(cfg);
   }
@@ -254,7 +260,8 @@
               merge.getParent(0),
               "BRANCH",
               merge.getParent(1),
-              m.getMergeResults());
+              m.getMergeResults(),
+              useDiff3);
     }
     logger.atFine().log("AutoMerge treeId=%s", treeId.name());
 
diff --git a/java/com/google/gerrit/server/patch/DiffFileSizeValidator.java b/java/com/google/gerrit/server/patch/DiffFileSizeValidator.java
deleted file mode 100644
index 14a0f7b..0000000
--- a/java/com/google/gerrit/server/patch/DiffFileSizeValidator.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.patch;
-
-import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_CORE_SECTION;
-import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_KEY_BIGFILE_THRESHOLD;
-import static org.eclipse.jgit.storage.pack.PackConfig.DEFAULT_BIG_FILE_THRESHOLD;
-
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.inject.Inject;
-import org.eclipse.jgit.lib.Config;
-
-public class DiffFileSizeValidator implements DiffValidator {
-  static final int DEFAULT_MAX_FILE_SIZE = DEFAULT_BIG_FILE_THRESHOLD;
-  private static final String ERROR_MESSAGE =
-      "File size for file %s exceeded the max file size threshold. Threshold = %d bytes, Actual size = %d bytes";
-
-  final int maxFileSize;
-
-  @Inject
-  public DiffFileSizeValidator(@GerritServerConfig Config cfg) {
-    this.maxFileSize =
-        cfg.getInt(CONFIG_CORE_SECTION, CONFIG_KEY_BIGFILE_THRESHOLD, DEFAULT_MAX_FILE_SIZE);
-  }
-
-  @Override
-  public void validate(FileDiffOutput fileDiff) throws LargeObjectException {
-    if (fileDiff.size() > maxFileSize) {
-      throw new LargeObjectException(
-          String.format(ERROR_MESSAGE, fileDiff.getDefaultPath(), maxFileSize, fileDiff.size()));
-    }
-    if (fileDiff.sizeDelta() > maxFileSize) {
-      throw new LargeObjectException(
-          String.format(
-              ERROR_MESSAGE, fileDiff.getDefaultPath(), maxFileSize, fileDiff.sizeDelta()));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/patch/DiffValidator.java b/java/com/google/gerrit/server/patch/DiffValidator.java
deleted file mode 100644
index aee3c8b..0000000
--- a/java/com/google/gerrit/server/patch/DiffValidator.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.patch;
-
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-
-/** Interface to validate diff outputs. */
-@ExtensionPoint
-public interface DiffValidator {
-  void validate(FileDiffOutput fileDiffOutput)
-      throws LargeObjectException, DiffNotAvailableException;
-}
diff --git a/java/com/google/gerrit/server/patch/DiffValidators.java b/java/com/google/gerrit/server/patch/DiffValidators.java
deleted file mode 100644
index 964353d..0000000
--- a/java/com/google/gerrit/server/patch/DiffValidators.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.patch;
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.inject.Inject;
-
-/** Validates {@link FileDiffOutput}(s) after they are computed by the {@link DiffOperations}. */
-public class DiffValidators {
-  DynamicSet<DiffValidator> diffValidators;
-
-  @Inject
-  public DiffValidators(DynamicSet<DiffValidator> diffValidators) {
-    this.diffValidators = diffValidators;
-  }
-
-  public void validate(FileDiffOutput fileDiffOutput)
-      throws LargeObjectException, DiffNotAvailableException {
-    for (DiffValidator validator : diffValidators) {
-      validator.validate(fileDiffOutput);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 5015c768..3baa3b1 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -94,7 +94,6 @@
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final DiffOperations diffOperations;
-  private final DiffValidators diffValidators;
 
   private final Change.Id changeId;
 
@@ -110,7 +109,6 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       DiffOperations diffOperations,
-      DiffValidators diffValidators,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
@@ -126,7 +124,6 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.diffOperations = diffOperations;
-    this.diffValidators = diffValidators;
 
     this.fileName = fileName;
     this.psa = patchSetA;
@@ -147,7 +144,6 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       DiffOperations diffOperations,
-      DiffValidators diffValidators,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
@@ -163,7 +159,6 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.diffOperations = diffOperations;
-    this.diffValidators = diffValidators;
 
     this.fileName = fileName;
     this.psa = null;
@@ -225,14 +220,13 @@
   }
 
   private PatchScript getPatchScript(Repository git, ObjectId aId, ObjectId bId)
-      throws IOException, DiffNotAvailableException, LargeObjectException {
+      throws IOException, DiffNotAvailableException {
     FileDiffOutput fileDiffOutput =
         aId == null
             ? diffOperations.getModifiedFileAgainstParent(
                 notes.getProjectName(), bId, parentNum, fileName, diffPrefs.ignoreWhitespace)
             : diffOperations.getModifiedFile(
                 notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
-    diffValidators.validate(fileDiffOutput);
     return newBuilder().toPatchScript(git, fileDiffOutput);
   }
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 9107dde..9286f47 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -64,10 +64,6 @@
    */
   public abstract Optional<String> newPath();
 
-  public String getDefaultPath() {
-    return oldPath().isPresent() ? oldPath().get() : newPath().get();
-  }
-
   /**
    * The file mode of the old file at the old git tree diff identified by {@link #oldCommitId()}
    * ()}.
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index a7b0743..f2ef497 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -28,10 +28,18 @@
     return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
 
+  public static Predicate<ProjectData> prefix(String prefix) {
+    return new ProjectPredicate(ProjectField.PREFIX_NAME_SPEC, prefix);
+  }
+
   public static Predicate<ProjectData> parent(Project.NameKey parentNameKey) {
     return new ProjectPredicate(ProjectField.PARENT_NAME_SPEC, parentNameKey.get());
   }
 
+  public static Predicate<ProjectData> parent2(Project.NameKey parentNameKey) {
+    return new ProjectPredicate(ProjectField.PARENT_NAME_2_SPEC, parentNameKey.get());
+  }
+
   public static Predicate<ProjectData> inname(String name) {
     return new ProjectPredicate(ProjectField.NAME_PART_SPEC, name.toLowerCase(Locale.US));
   }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index 616468e..ade6606 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -26,6 +26,7 @@
  */
 public interface ProjectQueryBuilder {
   String FIELD_LIMIT = "limit";
+  String FIELD_SUBSTRING = "substring";
 
   /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(String)}. */
   Predicate<ProjectData> parse(String query) throws QueryParseException;
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
index 599683e..b15bc6b 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
@@ -19,7 +19,12 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.project.ProjectSubstringPredicate;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -34,9 +39,12 @@
   private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilderImpl> mydef =
       new QueryBuilder.Definition<>(ProjectQueryBuilderImpl.class);
 
+  private final ProjectIndex index;
+
   @Inject
-  ProjectQueryBuilderImpl() {
+  ProjectQueryBuilderImpl(ProjectIndexCollection indexes) {
     super(mydef, null);
+    this.index = indexes.getSearchIndex();
   }
 
   @Operator
@@ -45,8 +53,22 @@
   }
 
   @Operator
+  public Predicate<ProjectData> prefix(String prefix) throws QueryParseException {
+    checkOperatorAvailable(ProjectField.PREFIX_NAME_SPEC, "prefix");
+    return ProjectPredicates.prefix(prefix);
+  }
+
+  @Operator
+  public Predicate<ProjectData> substring(String substring) {
+    return new ProjectSubstringPredicate(ProjectQueryBuilder.FIELD_SUBSTRING, substring);
+  }
+
+  @Operator
   public Predicate<ProjectData> parent(String parentName) {
-    return ProjectPredicates.parent(Project.nameKey(parentName));
+    if (!index.getSchema().hasField(ProjectField.PARENT_NAME_2_SPEC)) {
+      return ProjectPredicates.parent(Project.nameKey(parentName));
+    }
+    return ProjectPredicates.parent2(Project.nameKey(parentName));
   }
 
   @Operator
@@ -103,4 +125,17 @@
     }
     return new LimitPredicate<>(FIELD_LIMIT, limit);
   }
+
+  private void checkOperatorAvailable(SchemaField<ProjectData, ?> field, String operator)
+      throws QueryParseException {
+    checkFieldAvailable(
+        field, String.format("'%s' operator is not supported on this gerrit host", operator));
+  }
+
+  private void checkFieldAvailable(SchemaField<ProjectData, ?> field, String errorMessage)
+      throws QueryParseException {
+    if (!index.getSchema().hasField(field)) {
+      throw new QueryParseException(errorMessage);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index dffcf44..73991c9 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -24,6 +24,12 @@
 import com.google.gerrit.server.restapi.project.ProjectRestApiModule;
 import com.google.inject.AbstractModule;
 
+/**
+ * Module to bind REST API endpoints.
+ *
+ * <p>Classes that are needed by the REST layer, but which are not REST API endpoints, should be
+ * bound in {@link RestModule}.
+ */
 public class RestApiModule extends AbstractModule {
   @Override
   protected void configure() {
diff --git a/java/com/google/gerrit/server/restapi/RestModule.java b/java/com/google/gerrit/server/restapi/RestModule.java
new file mode 100644
index 0000000..0dcb4c8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/RestModule.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.UnimplementedPublicKeyStoreProvider;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.change.AddReviewersOp;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.DeleteChangeOp;
+import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
+import com.google.gerrit.server.change.DeleteReviewerOp;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
+import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.comment.CommentContextLoader;
+import com.google.gerrit.server.config.GerritConfigListener;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.restapi.change.DeleteVoteOp;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
+import com.google.gerrit.server.restapi.change.PreviewFix;
+import com.google.gerrit.server.restapi.project.CreateProject;
+import com.google.gerrit.server.restapi.project.ProjectNode;
+import com.google.gerrit.server.restapi.project.SetParent;
+import com.google.gerrit.server.util.AttentionSetEmail;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.inject.Provides;
+import com.google.inject.multibindings.OptionalBinder;
+
+/**
+ * Module to bind classes that are needed but the REST layer, but which are not REST endpoints.
+ *
+ * <p>REST endpoints should be bound in {@link RestApiModule}.
+ */
+public class RestModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    factory(AccountLoader.Factory.class);
+    factory(AddReviewersOp.Factory.class);
+    factory(AddToAttentionSetOp.Factory.class);
+    factory(AttentionSetEmail.Factory.class);
+    factory(ChangeInserter.Factory.class);
+    factory(ChangeResource.Factory.class);
+    factory(CommentContextLoader.Factory.class);
+    factory(DeleteChangeOp.Factory.class);
+    factory(DeleteReviewerByEmailOp.Factory.class);
+    factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteVoteOp.Factory.class);
+    factory(EmailReviewComments.Factory.class);
+    factory(GroupsUpdate.Factory.class);
+    factory(PatchSetInserter.Factory.class);
+    factory(PostReviewOp.Factory.class);
+    factory(PreviewFix.Factory.class);
+    factory(ProjectNode.Factory.class);
+    factory(RebaseChangeOp.Factory.class);
+    factory(RefValidationHelper.Factory.class);
+    factory(RemoveFromAttentionSetOp.Factory.class);
+    factory(ReviewerResource.Factory.class);
+    factory(SetCherryPickOp.Factory.class);
+    factory(SetCustomKeyedValuesOp.Factory.class);
+    factory(SetHashtagsOp.Factory.class);
+    factory(SetPrivateOp.Factory.class);
+    factory(SetTopicOp.Factory.class);
+    factory(WorkInProgressOp.Factory.class);
+
+    DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
+    DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
+        .to(CreateProject.ValidBranchListener.class);
+
+    OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+        .setDefault()
+        .toProvider(UnimplementedPublicKeyStoreProvider.class);
+  }
+
+  @Provides
+  @ServerInitiated
+  AccountsUpdate provideServerInitiatedAccountsUpdate(
+      @AccountsUpdate.AccountsUpdateLoader.WithReindex
+          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory) {
+    return accountsUpdateFactory.createWithServerIdent();
+  }
+
+  @Provides
+  @UserInitiated
+  AccountsUpdate provideUserInitiatedAccountsUpdate(
+      @AccountsUpdate.AccountsUpdateLoader.WithReindex
+          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory,
+      IdentifiedUser currentUser) {
+    return accountsUpdateFactory.create(currentUser);
+  }
+
+  @Provides
+  @ServerInitiated
+  GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
+    return groupsUpdateFactory.createWithServerIdent();
+  }
+
+  @Provides
+  @UserInitiated
+  GroupsUpdate provideUserInitiatedGroupsUpdate(
+      GroupsUpdate.Factory groupsUpdateFactory, IdentifiedUser currentUser) {
+    return groupsUpdateFactory.create(currentUser);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index 821d16e..a09e1bc 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -23,26 +23,14 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.UnimplementedPublicKeyStoreProvider;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.UserInitiated;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.inject.Provides;
-import com.google.inject.multibindings.OptionalBinder;
 
 public class AccountRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
-    bind(AccountsCollection.class);
+    bind(AccountsCollection.class).to(AccountsCollectionImpl.class);
     bind(Capabilities.class);
     bind(StarredChanges.Create.class);
 
-    OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
-        .setDefault()
-        .toProvider(UnimplementedPublicKeyStoreProvider.class);
-
     DynamicMap.mapOf(binder(), ACCOUNT_KIND);
     DynamicMap.mapOf(binder(), CAPABILITY_KIND);
     DynamicMap.mapOf(binder(), EMAIL_KIND);
@@ -113,21 +101,4 @@
     // The gpgkeys REST endpoints are bound via GpgApiModule.
     // The oauthtoken REST endpoint is bound via OAuthRestModule.
   }
-
-  @Provides
-  @ServerInitiated
-  AccountsUpdate provideServerInitiatedAccountsUpdate(
-      @AccountsUpdate.AccountsUpdateLoader.WithReindex
-          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory) {
-    return accountsUpdateFactory.createWithServerIdent();
-  }
-
-  @Provides
-  @UserInitiated
-  AccountsUpdate provideUserInitiatedAccountsUpdate(
-      @AccountsUpdate.AccountsUpdateLoader.WithReindex
-          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory,
-      IdentifiedUser currentUser) {
-    return accountsUpdateFactory.create(currentUser);
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 61ff6b8..fa919df 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2012 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -21,52 +21,19 @@
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.AccountResource;
-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 AccountsCollection implements RestCollection<TopLevelResource, AccountResource> {
-  private final AccountResolver accountResolver;
-  private final Provider<QueryAccounts> list;
-  private final DynamicMap<RestView<AccountResource>> views;
-
-  @Inject
-  public AccountsCollection(
-      AccountResolver accountResolver,
-      Provider<QueryAccounts> list,
-      DynamicMap<RestView<AccountResource>> views) {
-    this.accountResolver = accountResolver;
-    this.list = list;
-    this.views = views;
-  }
+/** A generic interface for parsing account IDs from URL resources. */
+public interface AccountsCollection extends RestCollection<TopLevelResource, AccountResource> {
+  @Override
+  AccountResource parse(TopLevelResource root, IdString id)
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException;
 
   @Override
-  public AccountResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
-    try {
-      return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
-    } catch (UnresolvableAccountException e) {
-      if (e.isSelf()) {
-        // Must be authenticated to use 'me' or 'self'.
-        throw new AuthException(e.getMessage(), e);
-      }
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    }
-  }
+  RestView<TopLevelResource> list() throws ResourceNotFoundException;
 
   @Override
-  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
-    return list.get();
-  }
-
-  @Override
-  public DynamicMap<RestView<AccountResource>> views() {
-    return views;
-  }
+  DynamicMap<RestView<AccountResource>> views();
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollectionImpl.java b/java/com/google/gerrit/server/restapi/account/AccountsCollectionImpl.java
new file mode 100644
index 0000000..141b01a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollectionImpl.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.account;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.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 AccountsCollectionImpl implements AccountsCollection {
+  private final AccountResolver accountResolver;
+  private final Provider<QueryAccounts> list;
+  private final DynamicMap<RestView<AccountResource>> views;
+
+  @Inject
+  public AccountsCollectionImpl(
+      AccountResolver accountResolver,
+      Provider<QueryAccounts> list,
+      DynamicMap<RestView<AccountResource>> views) {
+    this.accountResolver = accountResolver;
+    this.list = list;
+    this.views = views;
+  }
+
+  @Override
+  public AccountResource parse(TopLevelResource root, IdString id)
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
+    try {
+      return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
+    } catch (UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        // Must be authenticated to use 'me' or 'self'.
+        throw new AuthException(e.getMessage(), e);
+      }
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
+    return list.get();
+  }
+
+  @Override
+  public DynamicMap<RestView<AccountResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index ce89fc3..6adde99 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+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;
@@ -48,6 +51,7 @@
 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;
@@ -61,6 +65,7 @@
 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;
@@ -83,6 +88,8 @@
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ZoneId serverZoneId;
+  private final ProjectCache projectCache;
+  private final ChangeUtil changeUtil;
 
   @Inject
   ApplyPatch(
@@ -93,7 +100,9 @@
       BatchUpdate.Factory batchUpdateFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       Provider<InternalChangeQuery> queryProvider,
-      @GerritPersonIdent PersonIdent myIdent) {
+      @GerritPersonIdent PersonIdent myIdent,
+      ProjectCache projectCache,
+      ChangeUtil changeUtil) {
     this.jsonFactory = jsonFactory;
     this.contributorAgreements = contributorAgreements;
     this.user = user;
@@ -102,6 +111,8 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.queryProvider = queryProvider;
     this.serverZoneId = myIdent.getZoneId();
+    this.projectCache = projectCache;
+    this.changeUtil = changeUtil;
   }
 
   @Override
@@ -178,23 +189,26 @@
       ObjectId treeId = applyResult.getTreeId();
 
       Instant now = TimeUtil.now();
-      PersonIdent committerIdent = user.get().newCommitterIdent(now, serverZoneId);
+      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);
-      List<FooterLine> footerLines = latestPatchset.getFooterLines();
-      String messageWithNoFooters =
-          !Strings.isNullOrEmpty(input.commitMessage)
-              ? input.commitMessage
-              : removeFooters(latestPatchset.getFullMessage(), footerLines);
       String commitMessage =
-          ApplyPatchUtil.buildCommitMessage(
-              messageWithNoFooters,
-              footerLines,
-              input.patch.patch,
+          buildFullCommitMessage(
+              project,
+              latestPatchset,
+              input,
               ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
               applyResult.getErrors());
+
       ObjectId appliedCommit =
           CommitUtil.createCommitWithTree(
               oi, authorIdent, committerIdent, parents, commitMessage, treeId);
@@ -218,6 +232,42 @@
     }
   }
 
+  private String buildFullCommitMessage(
+      NameKey project,
+      RevCommit latestPatchset,
+      ApplyPatchPatchSetInput input,
+      String resultPatch,
+      List<org.eclipse.jgit.patch.PatchApplier.Result.Error> errors)
+      throws ResourceConflictException, BadRequestException {
+    boolean hasInputCommitMessage = !Strings.isNullOrEmpty(input.commitMessage);
+    String fullMessage =
+        hasInputCommitMessage ? input.commitMessage : latestPatchset.getFullMessage();
+    // Since we might add error information to the message, we need to split the footers from the
+    // actual description.
+    List<FooterLine> footerLines = FooterLine.fromMessage(fullMessage);
+    String messageWithNoFooters = removeFooters(fullMessage, footerLines);
+    if (FooterLine.getValues(footerLines, FOOTER_CHANGE_ID).isEmpty()) {
+      footerLines.add(
+          latestPatchset.getFooterLines().stream()
+              .filter(f -> f.matches(FOOTER_CHANGE_ID))
+              .findFirst()
+              .get());
+    }
+    String commitMessage =
+        ApplyPatchUtil.buildCommitMessage(
+            messageWithNoFooters, footerLines, input.patch.patch, resultPatch, errors);
+
+    boolean changeIdRequired =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .is(BooleanProjectConfig.REQUIRE_CHANGE_ID);
+    changeUtil.ensureChangeIdIsCorrect(
+        changeIdRequired, changeUtil.getChangeIdsFromFooter(latestPatchset).get(0), commitMessage);
+
+    return commitMessage;
+  }
+
   private static Change insertPatchSet(
       BatchUpdate bu,
       Repository git,
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 6b121f6..2ac24c6 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -29,29 +29,8 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.AddReviewersOp;
-import com.google.gerrit.server.change.AddToAttentionSetOp;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.DeleteChangeOp;
-import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
-import com.google.gerrit.server.change.DeleteReviewerOp;
-import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
-import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.change.SetCherryPickOp;
-import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
-import com.google.gerrit.server.change.SetHashtagsOp;
-import com.google.gerrit.server.change.SetPrivateOp;
-import com.google.gerrit.server.change.SetTopicOp;
-import com.google.gerrit.server.change.WorkInProgressOp;
-import com.google.gerrit.server.comment.CommentContextLoader;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
-import com.google.gerrit.server.util.AttentionSetEmail;
 
 public class ChangeRestApiModule extends RestApiModule {
   @Override
@@ -211,30 +190,5 @@
     post(VOTE_KIND, "delete").to(DeleteVote.class);
 
     post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
-
-    factory(AccountLoader.Factory.class);
-    factory(AddReviewersOp.Factory.class);
-    factory(AddToAttentionSetOp.Factory.class);
-    factory(AttentionSetEmail.Factory.class);
-    factory(ChangeInserter.Factory.class);
-    factory(ChangeResource.Factory.class);
-    factory(CommentContextLoader.Factory.class);
-    factory(DeleteChangeOp.Factory.class);
-    factory(DeleteReviewerByEmailOp.Factory.class);
-    factory(DeleteReviewerOp.Factory.class);
-    factory(DeleteVoteOp.Factory.class);
-    factory(EmailReviewComments.Factory.class);
-    factory(PatchSetInserter.Factory.class);
-    factory(PostReviewOp.Factory.class);
-    factory(PreviewFix.Factory.class);
-    factory(RebaseChangeOp.Factory.class);
-    factory(RemoveFromAttentionSetOp.Factory.class);
-    factory(ReviewerResource.Factory.class);
-    factory(SetCherryPickOp.Factory.class);
-    factory(SetCustomKeyedValuesOp.Factory.class);
-    factory(SetHashtagsOp.Factory.class);
-    factory(SetPrivateOp.Factory.class);
-    factory(SetTopicOp.Factory.class);
-    factory(WorkInProgressOp.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 1bfb6bd..8b99e1c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -74,6 +74,7 @@
 import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -306,8 +307,15 @@
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverZoneId);
-
+      PersonIdent committerIdent =
+          Optional.ofNullable(commitToCherryPick.getCommitterIdent())
+              .map(
+                  ident ->
+                      identifiedUser
+                          .newCommitterIdent(ident.getEmailAddress(), timestamp, serverZoneId)
+                          .orElseGet(
+                              () -> identifiedUser.newCommitterIdent(timestamp, serverZoneId)))
+              .orElseGet(() -> identifiedUser.newCommitterIdent(timestamp, serverZoneId));
       try {
         MergeUtil mergeUtil;
         if (input.allowConflicts) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 27e5e66..989dc7a 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -73,6 +74,7 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -181,11 +183,18 @@
 
       Instant now = TimeUtil.now();
       IdentifiedUser me = user.get().asIdentifiedUser();
-      PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
       PersonIdent author =
           in.author == null
-              ? committer
+              ? me.newCommitterIdent(now, serverZoneId)
               : new PersonIdent(in.author.name, in.author.email, now, serverZoneId);
+      RevCommit commit = rw.parseCommit(ps.commitId());
+      PersonIdent committer =
+          Optional.ofNullable(commit.getCommitterIdent())
+              .map(
+                  ident ->
+                      me.newCommitterIdent(ident.getEmailAddress(), now, serverZoneId)
+                          .orElseGet(() -> me.newCommitterIdent(now, serverZoneId)))
+              .orElseGet(() -> me.newCommitterIdent(now, serverZoneId));
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
@@ -212,6 +221,16 @@
               .setMessage(messageForChange(nextPsId, newCommit))
               .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty())
               .setCheckAddPatchSetPermission(false);
+
+          if (in.validationOptions != null) {
+            ImmutableListMultimap.Builder<String, String> validationOptions =
+                ImmutableListMultimap.builder();
+            in.validationOptions
+                .entrySet()
+                .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
+            psInserter.setValidationOptions(validationOptions.build());
+          }
+
           if (groups != null) {
             psInserter.setGroups(groups);
           }
diff --git a/java/com/google/gerrit/server/restapi/change/DownloadContent.java b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
index 74c0acc..ae801b4 100644
--- a/java/com/google/gerrit/server/restapi/change/DownloadContent.java
+++ b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -44,7 +45,7 @@
 
   @Override
   public Response<BinaryResult> apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException {
+      throws BadRequestException, ResourceNotFoundException, IOException, NoSuchChangeException {
     String path = rsrc.getPatchKey().fileName();
     RevisionResource rev = rsrc.getRevision();
     return Response.ok(
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 710c09c..32474a4 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -179,6 +180,7 @@
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
+  private final ChangeJson.Factory changeJsonFactory;
 
   @Inject
   PostReview(
@@ -201,7 +203,8 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
-      ReviewerAdded reviewerAdded) {
+      ReviewerAdded reviewerAdded,
+      ChangeJson.Factory changeJsonFactory) {
     this.updateFactory = updateFactory;
     this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
@@ -222,6 +225,7 @@
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
+    this.changeJsonFactory = changeJsonFactory;
   }
 
   @Override
@@ -409,6 +413,10 @@
     batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
     batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
 
+    if (input.responseFormatOptions != null) {
+      output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd);
+    }
+
     return Response.ok(output);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 8479d91..a47e179 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -97,7 +97,7 @@
 import org.eclipse.jgit.lib.Config;
 
 public class PostReviewOp implements BatchUpdateOp {
-  interface Factory {
+  public interface Factory {
     PostReviewOp create(
         ProjectState projectState, PatchSet.Id psId, ReviewInput in, Account.Id reviewerId);
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 4eca1f3..3717e02 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -51,6 +52,7 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -175,8 +177,15 @@
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(
-        userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, zoneId));
+    IdentifiedUser user = userProvider.get().asIdentifiedUser();
+    PersonIdent committer =
+        Optional.ofNullable(basePatchSetCommit.getCommitterIdent())
+            .map(
+                ident ->
+                    user.newCommitterIdent(ident.getEmailAddress(), timestamp, zoneId)
+                        .orElseGet(() -> user.newCommitterIdent(timestamp, zoneId)))
+            .orElseGet(() -> user.newCommitterIdent(timestamp, zoneId));
+    builder.setCommitter(committer);
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
diff --git a/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
index 1eaa6a2..f115374 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
@@ -20,17 +20,12 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.UserInitiated;
-import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.restapi.group.AddMembers.CreateMember;
 import com.google.gerrit.server.restapi.group.AddMembers.UpdateMember;
 import com.google.gerrit.server.restapi.group.AddSubgroups.CreateSubgroup;
 import com.google.gerrit.server.restapi.group.AddSubgroups.UpdateSubgroup;
 import com.google.gerrit.server.restapi.group.DeleteMembers.DeleteMember;
 import com.google.gerrit.server.restapi.group.DeleteSubgroups.DeleteSubgroup;
-import com.google.inject.Provides;
 
 public class GroupRestApiModule extends RestApiModule {
 
@@ -77,20 +72,5 @@
     put(GROUP_KIND, "options").to(PutOptions.class);
     get(GROUP_KIND, "owner").to(GetOwner.class);
     put(GROUP_KIND, "owner").to(PutOwner.class);
-
-    factory(GroupsUpdate.Factory.class);
-  }
-
-  @Provides
-  @ServerInitiated
-  GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
-    return groupsUpdateFactory.createWithServerIdent();
-  }
-
-  @Provides
-  @UserInitiated
-  GroupsUpdate provideUserInitiatedGroupsUpdate(
-      GroupsUpdate.Factory groupsUpdateFactory, IdentifiedUser currentUser) {
-    return groupsUpdateFactory.create(currentUser);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/AbstractListProjects.java b/java/com/google/gerrit/server/restapi/project/AbstractListProjects.java
new file mode 100644
index 0000000..6467d81
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/AbstractListProjects.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.json.OutputFormat;
+import org.kohsuke.args4j.Option;
+
+/**
+ * Base class for {@link ListProjects} implementations.
+ *
+ * <p>Defines the options that are supported by the list projects REST endpoint.
+ */
+public abstract class AbstractListProjects implements ListProjects {
+  @Override
+  @Option(name = "--format", usage = "(deprecated) output format")
+  public abstract void setFormat(OutputFormat fmt);
+
+  @Override
+  @Option(
+      name = "--show-branch",
+      aliases = {"-b"},
+      usage = "displays the sha of each project in the specified branch")
+  public abstract void addShowBranch(String branch);
+
+  @Override
+  @Option(
+      name = "--tree",
+      aliases = {"-t"},
+      usage =
+          "displays project inheritance in a tree-like format\n"
+              + "this option does not work together with the show-branch option")
+  public abstract void setShowTree(boolean showTree);
+
+  @Override
+  @Option(name = "--type", usage = "type of project")
+  public abstract void setFilterType(FilterType type);
+
+  @Override
+  @Option(
+      name = "--description",
+      aliases = {"-d"},
+      usage = "include description of project in list")
+  public abstract void setShowDescription(boolean showDescription);
+
+  @Override
+  @Option(name = "--all", usage = "display all projects that are accessible by the calling user")
+  public abstract void setAll(boolean all);
+
+  @Override
+  @Option(
+      name = "--state",
+      aliases = {"-s"},
+      usage = "filter by project state")
+  public abstract void setState(com.google.gerrit.extensions.client.ProjectState state);
+
+  @Override
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of projects to list")
+  public abstract void setLimit(int limit);
+
+  @Override
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of projects to skip")
+  public abstract void setStart(int start);
+
+  @Override
+  @Option(
+      name = "--prefix",
+      aliases = {"-p"},
+      metaVar = "PREFIX",
+      usage = "match project prefix")
+  public abstract void setMatchPrefix(String matchPrefix);
+
+  @Override
+  @Option(
+      name = "--match",
+      aliases = {"-m"},
+      metaVar = "MATCH",
+      usage = "match project substring")
+  public abstract void setMatchSubstring(String matchSubstring);
+
+  @Override
+  @Option(name = "-r", metaVar = "REGEX", usage = "match project regex")
+  public abstract void setMatchRegex(String matchRegex);
+
+  @Override
+  @Option(
+      name = "--has-acl-for",
+      metaVar = "GROUP",
+      usage = "displays only projects on which access rights for this group are directly assigned")
+  public abstract void setGroupUuid(AccountGroup.UUID groupUuid);
+
+  @Override
+  public Response<Object> apply(TopLevelResource resource) throws Exception {
+    return Response.ok(apply());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index cfdadd9..04819d8 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -227,7 +227,7 @@
     return branch;
   }
 
-  static class ValidBranchListener implements ProjectCreationValidationListener {
+  public static class ValidBranchListener implements ProjectCreationValidationListener {
     @Override
     public void validateNewProject(CreateProjectArgs args) throws ValidationException {
       for (String branch : args.branch) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 1f40de6..3ce8708 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -14,90 +14,25 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.common.base.Strings.emptyToNull;
-import static com.google.common.base.Strings.isNullOrEmpty;
-import static com.google.common.collect.Ordering.natural;
-import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSortedMap;
-import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.GroupResolver;
-import com.google.gerrit.server.ioutil.RegexListSearcher;
-import com.google.gerrit.server.ioutil.StringUtil;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-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.ProjectState;
-import com.google.gerrit.server.util.TreeFormatter;
-import com.google.gson.reflect.TypeToken;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.BufferedWriter;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.NavigableSet;
-import java.util.Optional;
 import java.util.SortedMap;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import java.util.stream.Stream;
-import java.util.stream.StreamSupport;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
 
 /**
  * List projects visible to the calling user.
  *
  * <p>Implement {@code GET /projects/}, without a {@code query=} parameter.
  */
-public class ListProjects implements RestReadView<TopLevelResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
+public interface ListProjects extends RestReadView<TopLevelResource> {
   public enum FilterType {
     CODE {
       @Override
@@ -141,598 +76,36 @@
     abstract boolean useMatch();
   }
 
-  private final CurrentUser currentUser;
-  private final ProjectCache projectCache;
-  private final GroupResolver groupResolver;
-  private final GroupControl.Factory groupControlFactory;
-  private final GitRepositoryManager repoManager;
-  private final PermissionBackend permissionBackend;
-  private final ProjectNode.Factory projectNodeFactory;
-  private final WebLinks webLinks;
+  void setFormat(OutputFormat fmt);
 
-  @Deprecated
-  @Option(name = "--format", usage = "(deprecated) output format")
-  private OutputFormat format = OutputFormat.TEXT;
+  void addShowBranch(String branch);
 
-  @Option(
-      name = "--show-branch",
-      aliases = {"-b"},
-      usage = "displays the sha of each project in the specified branch")
-  public void addShowBranch(String branch) {
-    showBranch.add(branch);
-  }
+  void setShowTree(boolean showTree);
 
-  @Option(
-      name = "--tree",
-      aliases = {"-t"},
-      usage =
-          "displays project inheritance in a tree-like format\n"
-              + "this option does not work together with the show-branch option")
-  public void setShowTree(boolean showTree) {
-    this.showTree = showTree;
-  }
+  void setFilterType(FilterType type);
 
-  @Option(name = "--type", usage = "type of project")
-  public void setFilterType(FilterType type) {
-    this.type = type;
-  }
+  void setShowDescription(boolean showDescription);
 
-  @Option(
-      name = "--description",
-      aliases = {"-d"},
-      usage = "include description of project in list")
-  public void setShowDescription(boolean showDescription) {
-    this.showDescription = showDescription;
-  }
+  void setAll(boolean all);
 
-  @Option(name = "--all", usage = "display all projects that are accessible by the calling user")
-  public void setAll(boolean all) {
-    this.all = all;
-  }
+  void setState(com.google.gerrit.extensions.client.ProjectState state);
 
-  @Option(
-      name = "--state",
-      aliases = {"-s"},
-      usage = "filter by project state")
-  public void setState(com.google.gerrit.extensions.client.ProjectState state) {
-    this.state = state;
-  }
+  void setLimit(int limit);
 
-  @Option(
-      name = "--limit",
-      aliases = {"-n"},
-      metaVar = "CNT",
-      usage = "maximum number of projects to list")
-  public void setLimit(int limit) {
-    this.limit = limit;
-  }
+  void setStart(int start);
 
-  @Option(
-      name = "--start",
-      aliases = {"-S"},
-      metaVar = "CNT",
-      usage = "number of projects to skip")
-  public void setStart(int start) {
-    this.start = start;
-  }
+  void setMatchPrefix(String matchPrefix);
 
-  @Option(
-      name = "--prefix",
-      aliases = {"-p"},
-      metaVar = "PREFIX",
-      usage = "match project prefix")
-  public void setMatchPrefix(String matchPrefix) {
-    this.matchPrefix = matchPrefix;
-  }
+  void setMatchSubstring(String matchSubstring);
 
-  @Option(
-      name = "--match",
-      aliases = {"-m"},
-      metaVar = "MATCH",
-      usage = "match project substring")
-  public void setMatchSubstring(String matchSubstring) {
-    this.matchSubstring = matchSubstring;
-  }
+  void setMatchRegex(String matchRegex);
 
-  @Option(name = "-r", metaVar = "REGEX", usage = "match project regex")
-  public void setMatchRegex(String matchRegex) {
-    this.matchRegex = matchRegex;
-  }
-
-  @Option(
-      name = "--has-acl-for",
-      metaVar = "GROUP",
-      usage = "displays only projects on which access rights for this group are directly assigned")
-  public void setGroupUuid(AccountGroup.UUID groupUuid) {
-    this.groupUuid = groupUuid;
-  }
-
-  private final List<String> showBranch = new ArrayList<>();
-  private boolean showTree;
-  private FilterType type = FilterType.ALL;
-  private boolean showDescription;
-  private boolean all;
-  private com.google.gerrit.extensions.client.ProjectState state;
-  private int limit;
-  private int start;
-  private String matchPrefix;
-  private String matchSubstring;
-  private String matchRegex;
-  private AccountGroup.UUID groupUuid;
-  private final Provider<QueryProjects> queryProjectsProvider;
-  private final boolean listProjectsFromIndex;
-
-  @Inject
-  protected ListProjects(
-      CurrentUser currentUser,
-      ProjectCache projectCache,
-      GroupResolver groupResolver,
-      GroupControl.Factory groupControlFactory,
-      GitRepositoryManager repoManager,
-      PermissionBackend permissionBackend,
-      ProjectNode.Factory projectNodeFactory,
-      WebLinks webLinks,
-      Provider<QueryProjects> queryProjectsProvider,
-      @GerritServerConfig Config config) {
-    this.currentUser = currentUser;
-    this.projectCache = projectCache;
-    this.groupResolver = groupResolver;
-    this.groupControlFactory = groupControlFactory;
-    this.repoManager = repoManager;
-    this.permissionBackend = permissionBackend;
-    this.projectNodeFactory = projectNodeFactory;
-    this.webLinks = webLinks;
-    this.queryProjectsProvider = queryProjectsProvider;
-    this.listProjectsFromIndex = config.getBoolean("gerrit", "listProjectsFromIndex", false);
-  }
-
-  public List<String> getShowBranch() {
-    return showBranch;
-  }
-
-  public boolean isShowTree() {
-    return showTree;
-  }
-
-  public boolean isShowDescription() {
-    return showDescription;
-  }
-
-  public OutputFormat getFormat() {
-    return format;
-  }
-
-  public ListProjects setFormat(OutputFormat fmt) {
-    format = fmt;
-    return this;
-  }
+  void setGroupUuid(AccountGroup.UUID groupUuid);
 
   @Override
-  public Response<Object> apply(TopLevelResource resource)
-      throws BadRequestException, PermissionBackendException {
-    if (format == OutputFormat.TEXT) {
-      ByteArrayOutputStream buf = new ByteArrayOutputStream();
-      displayToStream(buf);
-      return Response.ok(
-          BinaryResult.create(buf.toByteArray())
-              .setContentType("text/plain")
-              .setCharacterEncoding(UTF_8));
-    }
+  default Response<Object> apply(TopLevelResource resource) throws Exception {
     return Response.ok(apply());
   }
 
-  public SortedMap<String, ProjectInfo> apply()
-      throws BadRequestException, PermissionBackendException {
-    Optional<String> projectQuery = expressAsProjectsQuery();
-    if (projectQuery.isPresent()) {
-      return applyAsQuery(projectQuery.get());
-    }
-
-    format = OutputFormat.JSON;
-    return display(null);
-  }
-
-  private Optional<String> expressAsProjectsQuery() {
-    return listProjectsFromIndex
-            && !all
-            && state != HIDDEN
-            && isNullOrEmpty(matchPrefix)
-            && isNullOrEmpty(matchRegex)
-            && isNullOrEmpty(
-                matchSubstring) // TODO: see https://issues.gerritcodereview.com/issues/40010295
-            && type == FilterType.ALL
-            && showBranch.isEmpty()
-            && !showTree
-        ? Optional.of(stateToQuery())
-        : Optional.empty();
-  }
-
-  private String stateToQuery() {
-    List<String> queries = new ArrayList<>();
-    if (state == null) {
-      queries.add("(state:active OR state:read-only)");
-    } else {
-      queries.add(String.format("(state:%s)", state.name()));
-    }
-
-    return Joiner.on(" AND ").join(queries);
-  }
-
-  private SortedMap<String, ProjectInfo> applyAsQuery(String query) throws BadRequestException {
-    try {
-      return queryProjectsProvider.get().withQuery(query).withStart(start).withLimit(limit).apply()
-          .stream()
-          .collect(
-              ImmutableSortedMap.toImmutableSortedMap(
-                  natural(), p -> p.name, p -> showDescription ? p : nullifyDescription(p)));
-    } catch (StorageException | MethodNotAllowedException e) {
-      logger.atWarning().withCause(e).log(
-          "Internal error while processing the query '%s' request", query);
-      throw new BadRequestException("Internal error while processing the query request");
-    }
-  }
-
-  private ProjectInfo nullifyDescription(ProjectInfo p) {
-    p.description = null;
-    return p;
-  }
-
-  private void printQueryResults(String query, PrintWriter out) throws BadRequestException {
-    try {
-      if (format.isJson()) {
-        format.newGson().toJson(applyAsQuery(query), out);
-      } else {
-        newProjectsNamesStream(query).forEach(out::println);
-      }
-      out.flush();
-    } catch (StorageException | MethodNotAllowedException e) {
-      logger.atWarning().withCause(e).log(
-          "Internal error while processing the query '%s' request", query);
-      throw new BadRequestException("Internal error while processing the query request");
-    }
-  }
-
-  private Stream<String> newProjectsNamesStream(String query)
-      throws MethodNotAllowedException, BadRequestException {
-    Stream<String> projects =
-        queryProjectsProvider.get().withQuery(query).apply().stream().map(p -> p.name).skip(start);
-    if (limit > 0) {
-      projects = projects.limit(limit);
-    }
-
-    return projects;
-  }
-
-  public void displayToStream(OutputStream displayOutputStream)
-      throws BadRequestException, PermissionBackendException {
-    PrintWriter stdout =
-        new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
-    Optional<String> projectsQuery = expressAsProjectsQuery();
-
-    if (projectsQuery.isPresent()) {
-      printQueryResults(projectsQuery.get(), stdout);
-    } else {
-      display(stdout);
-    }
-  }
-
-  @Nullable
-  public SortedMap<String, ProjectInfo> display(@Nullable PrintWriter stdout)
-      throws BadRequestException, PermissionBackendException {
-    if (all && state != null) {
-      throw new BadRequestException("'all' and 'state' may not be used together");
-    }
-    if (!isGroupVisible()) {
-      return Collections.emptySortedMap();
-    }
-
-    int foundIndex = 0;
-    int found = 0;
-    TreeMap<String, ProjectInfo> output = new TreeMap<>();
-    Map<String, String> hiddenNames = new HashMap<>();
-    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
-    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
-    final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
-    try {
-      Iterator<ProjectState> projectStatesIt = filter(perm).iterator();
-      while (projectStatesIt.hasNext()) {
-        ProjectState e = projectStatesIt.next();
-        Project.NameKey projectName = e.getNameKey();
-        if (e.getProject().getState() == HIDDEN && !all && state != HIDDEN) {
-          // If we can't get it from the cache, pretend it's not present.
-          // If all wasn't selected, and it's HIDDEN, pretend it's not present.
-          // If state HIDDEN wasn't selected, and it's HIDDEN, pretend it's not present.
-          continue;
-        }
-
-        if (state != null && e.getProject().getState() != state) {
-          continue;
-        }
-
-        if (groupUuid != null
-            && !e.getLocalGroups()
-                .contains(GroupReference.forGroup(groupResolver.parseId(groupUuid.get())))) {
-          continue;
-        }
-
-        if (showTree && !format.isJson()) {
-          treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true));
-          continue;
-        }
-
-        if (foundIndex++ < start) {
-          continue;
-        }
-        if (limit > 0 && ++found > limit) {
-          break;
-        }
-
-        ProjectInfo info = new ProjectInfo();
-        info.name = projectName.get();
-        if (showTree && format.isJson()) {
-          addParentProjectInfo(hiddenNames, accessibleParents, perm, e, info);
-        }
-
-        if (showDescription) {
-          info.description = emptyToNull(e.getProject().getDescription());
-        }
-        info.state = e.getProject().getState();
-
-        try {
-          if (!showBranch.isEmpty()) {
-            try (Repository git = repoManager.openRepository(projectName)) {
-              if (!type.matches(git)) {
-                continue;
-              }
-
-              List<Ref> refs = retrieveBranchRefs(e, git);
-              if (!hasValidRef(refs)) {
-                continue;
-              }
-
-              addProjectBranchesInfo(info, refs);
-            }
-          } else if (!showTree && type.useMatch()) {
-            try (Repository git = repoManager.openRepository(projectName)) {
-              if (!type.matches(git)) {
-                continue;
-              }
-            }
-          }
-        } catch (RepositoryNotFoundException err) {
-          // If the Git repository is gone, the project doesn't actually exist anymore.
-          continue;
-        } catch (IOException err) {
-          logger.atWarning().withCause(err).log("Unexpected error reading %s", projectName);
-          continue;
-        }
-
-        ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
-        info.webLinks = links.isEmpty() ? null : links;
-
-        if (stdout == null || format.isJson()) {
-          output.put(info.name, info);
-          continue;
-        }
-
-        if (!showBranch.isEmpty()) {
-          printProjectBranches(stdout, info);
-        }
-        stdout.print(info.name);
-
-        if (info.description != null) {
-          // We still want to list every project as one-liners, hence escaping \n.
-          stdout.print(" - " + StringUtil.escapeString(info.description));
-        }
-        stdout.print('\n');
-      }
-
-      for (ProjectInfo info : output.values()) {
-        info.id = Url.encode(info.name);
-        info.name = null;
-      }
-      if (stdout == null) {
-        return output;
-      } else if (format.isJson()) {
-        format
-            .newGson()
-            .toJson(output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
-        stdout.print('\n');
-      } else if (showTree && treeMap.size() > 0) {
-        printProjectTree(stdout, treeMap);
-      }
-      return null;
-    } finally {
-      if (stdout != null) {
-        stdout.flush();
-      }
-    }
-  }
-
-  private boolean isGroupVisible() {
-    try {
-      return groupUuid == null || groupControlFactory.controlFor(groupUuid).isVisible();
-    } catch (NoSuchGroupException ex) {
-      return false;
-    }
-  }
-
-  private void printProjectBranches(PrintWriter stdout, ProjectInfo info) {
-    for (String name : showBranch) {
-      String ref = info.branches != null ? info.branches.get(name) : null;
-      if (ref == null) {
-        // Print stub (forty '-' symbols)
-        ref = "----------------------------------------";
-      }
-      stdout.print(ref);
-      stdout.print(' ');
-    }
-  }
-
-  private void addProjectBranchesInfo(ProjectInfo info, List<Ref> refs) {
-    for (int i = 0; i < showBranch.size(); i++) {
-      Ref ref = refs.get(i);
-      if (ref != null && ref.getObjectId() != null) {
-        if (info.branches == null) {
-          info.branches = new LinkedHashMap<>();
-        }
-        info.branches.put(showBranch.get(i), ref.getObjectId().name());
-      }
-    }
-  }
-
-  private List<Ref> retrieveBranchRefs(ProjectState e, Repository git) {
-    if (!e.statePermitsRead()) {
-      return ImmutableList.of();
-    }
-
-    return getBranchRefs(e.getNameKey(), git);
-  }
-
-  private void addParentProjectInfo(
-      Map<String, String> hiddenNames,
-      Map<Project.NameKey, Boolean> accessibleParents,
-      PermissionBackend.WithUser perm,
-      ProjectState e,
-      ProjectInfo info)
-      throws PermissionBackendException {
-    ProjectState parent = Iterables.getFirst(e.parents(), null);
-    if (parent != null) {
-      if (isParentAccessible(accessibleParents, perm, parent)) {
-        info.parent = parent.getName();
-      } else {
-        info.parent = hiddenNames.get(parent.getName());
-        if (info.parent == null) {
-          info.parent = "?-" + (hiddenNames.size() + 1);
-          hiddenNames.put(parent.getName(), info.parent);
-        }
-      }
-    }
-  }
-
-  private Stream<ProjectState> filter(PermissionBackend.WithUser perm) throws BadRequestException {
-    return StreamSupport.stream(scan().spliterator(), false)
-        .map(projectCache::get)
-        .filter(Optional::isPresent)
-        .map(Optional::get)
-        .filter(p -> permissionCheck(p, perm));
-  }
-
-  private boolean permissionCheck(ProjectState state, PermissionBackend.WithUser perm) {
-    // Hidden projects(permitsRead = false) should only be accessible by the project owners.
-    // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
-    // be allowed for other users). Allowing project owners to access here will help them to view
-    // and update the config of hidden projects easily.
-    return perm.project(state.getNameKey())
-        .testOrFalse(
-            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG);
-  }
-
-  private boolean isParentAccessible(
-      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState state)
-      throws PermissionBackendException {
-    Project.NameKey name = state.getNameKey();
-    Boolean b = checked.get(name);
-    if (b == null) {
-      try {
-        // Hidden projects(permitsRead = false) should only be accessible by the project owners.
-        // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
-        // be allowed for other users). Allowing project owners to access here will help them to
-        // view
-        // and update the config of hidden projects easily.
-        ProjectPermission permissionToCheck =
-            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
-        perm.project(name).check(permissionToCheck);
-        b = true;
-      } catch (AuthException denied) {
-        b = false;
-      }
-      checked.put(name, b);
-    }
-    return b;
-  }
-
-  private Stream<Project.NameKey> scan() throws BadRequestException {
-    if (matchPrefix != null) {
-      checkMatchOptions(matchSubstring == null && matchRegex == null);
-      return projectCache.byName(matchPrefix).stream();
-    } else if (matchSubstring != null) {
-      checkMatchOptions(matchPrefix == null && matchRegex == null);
-      return projectCache.all().stream()
-          .filter(
-              p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
-    } else if (matchRegex != null) {
-      checkMatchOptions(matchPrefix == null && matchSubstring == null);
-      RegexListSearcher<Project.NameKey> searcher;
-      try {
-        searcher = new RegexListSearcher<>(matchRegex, Project.NameKey::get);
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException(e.getMessage());
-      }
-      return searcher.search(projectCache.all().asList());
-    } else {
-      return projectCache.all().stream();
-    }
-  }
-
-  private static void checkMatchOptions(boolean cond) throws BadRequestException {
-    if (!cond) {
-      throw new BadRequestException("specify exactly one of p/m/r");
-    }
-  }
-
-  private void printProjectTree(
-      final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
-    final NavigableSet<ProjectNode> sortedNodes = new TreeSet<>();
-
-    // Builds the inheritance tree using a list.
-    //
-    for (ProjectNode key : treeMap.values()) {
-      if (key.isAllProjects()) {
-        sortedNodes.add(key);
-        continue;
-      }
-
-      ProjectNode node = treeMap.get(key.getParentName());
-      if (node != null) {
-        node.addChild(key);
-      } else {
-        sortedNodes.add(key);
-      }
-    }
-
-    final TreeFormatter treeFormatter = new TreeFormatter(stdout);
-    treeFormatter.printTree(sortedNodes);
-    stdout.flush();
-  }
-
-  private List<Ref> getBranchRefs(Project.NameKey projectName, Repository git) {
-    Ref[] result = new Ref[showBranch.size()];
-    try {
-      PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
-      for (int i = 0; i < showBranch.size(); i++) {
-        Ref ref = git.findRef(showBranch.get(i));
-        if (ref != null && ref.getObjectId() != null) {
-          try {
-            perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
-            result[i] = ref;
-          } catch (AuthException e) {
-            continue;
-          }
-        }
-      }
-    } catch (IOException | PermissionBackendException e) {
-      // Fall through and return what is available.
-    }
-    return Arrays.asList(result);
-  }
-
-  private static boolean hasValidRef(List<Ref> refs) {
-    for (Ref ref : refs) {
-      if (ref != null) {
-        return true;
-      }
-    }
-    return false;
-  }
+  SortedMap<String, ProjectInfo> apply() throws Exception;
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java b/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java
new file mode 100644
index 0000000..88aec1a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java
@@ -0,0 +1,686 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.isNullOrEmpty;
+import static com.google.common.collect.Ordering.natural;
+import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
+import com.google.gerrit.server.ioutil.StringUtil;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+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.ProjectState;
+import com.google.gerrit.server.util.TreeFormatter;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.NavigableSet;
+import java.util.Optional;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * List projects visible to the calling user.
+ *
+ * <p>Implement {@code GET /projects/}, without a {@code query=} parameter.
+ */
+public class ListProjectsImpl extends AbstractListProjects {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final CurrentUser currentUser;
+  private final ProjectCache projectCache;
+  private final GroupResolver groupResolver;
+  private final GroupControl.Factory groupControlFactory;
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final ProjectNode.Factory projectNodeFactory;
+  private final WebLinks webLinks;
+
+  @Override
+  public void setFormat(OutputFormat fmt) {
+    format = fmt;
+  }
+
+  @Override
+  public void addShowBranch(String branch) {
+    showBranch.add(branch);
+  }
+
+  @Override
+  public void setShowTree(boolean showTree) {
+    this.showTree = showTree;
+  }
+
+  @Override
+  public void setFilterType(FilterType type) {
+    this.type = type;
+  }
+
+  @Override
+  public void setShowDescription(boolean showDescription) {
+    this.showDescription = showDescription;
+  }
+
+  @Override
+  public void setAll(boolean all) {
+    this.all = all;
+  }
+
+  @Override
+  public void setState(com.google.gerrit.extensions.client.ProjectState state) {
+    this.state = state;
+  }
+
+  @Override
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Override
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Override
+  public void setMatchPrefix(String matchPrefix) {
+    this.matchPrefix = matchPrefix;
+  }
+
+  @Override
+  public void setMatchSubstring(String matchSubstring) {
+    this.matchSubstring = matchSubstring;
+  }
+
+  @Override
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
+
+  @Override
+  public void setGroupUuid(AccountGroup.UUID groupUuid) {
+    this.groupUuid = groupUuid;
+  }
+
+  @Deprecated private OutputFormat format = OutputFormat.TEXT;
+  private final List<String> showBranch = new ArrayList<>();
+  private boolean showTree;
+  private FilterType type = FilterType.ALL;
+  private boolean showDescription;
+  private boolean all;
+  private com.google.gerrit.extensions.client.ProjectState state;
+  private int limit;
+  private int start;
+  private String matchPrefix;
+  private String matchSubstring;
+  private String matchRegex;
+  private AccountGroup.UUID groupUuid;
+  private final Provider<QueryProjects> queryProjectsProvider;
+  private final boolean listProjectsFromIndex;
+  private final ProjectIndexCollection projectIndexes;
+
+  @Inject
+  protected ListProjectsImpl(
+      CurrentUser currentUser,
+      ProjectCache projectCache,
+      GroupResolver groupResolver,
+      GroupControl.Factory groupControlFactory,
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      ProjectNode.Factory projectNodeFactory,
+      WebLinks webLinks,
+      Provider<QueryProjects> queryProjectsProvider,
+      @GerritServerConfig Config config,
+      ProjectIndexCollection projectIndexes) {
+    this.currentUser = currentUser;
+    this.projectCache = projectCache;
+    this.groupResolver = groupResolver;
+    this.groupControlFactory = groupControlFactory;
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.projectNodeFactory = projectNodeFactory;
+    this.webLinks = webLinks;
+    this.queryProjectsProvider = queryProjectsProvider;
+    this.listProjectsFromIndex = config.getBoolean("gerrit", "listProjectsFromIndex", false);
+    this.projectIndexes = projectIndexes;
+  }
+
+  public List<String> getShowBranch() {
+    return showBranch;
+  }
+
+  public boolean isShowTree() {
+    return showTree;
+  }
+
+  public boolean isShowDescription() {
+    return showDescription;
+  }
+
+  public OutputFormat getFormat() {
+    return format;
+  }
+
+  @Override
+  public Response<Object> apply(TopLevelResource resource)
+      throws BadRequestException, PermissionBackendException {
+    if (format == OutputFormat.TEXT) {
+      ByteArrayOutputStream buf = new ByteArrayOutputStream();
+      displayToStream(buf);
+      return Response.ok(
+          BinaryResult.create(buf.toByteArray())
+              .setContentType("text/plain")
+              .setCharacterEncoding(UTF_8));
+    }
+    return Response.ok(apply());
+  }
+
+  @Override
+  public SortedMap<String, ProjectInfo> apply()
+      throws BadRequestException, PermissionBackendException {
+    Optional<String> projectQuery = expressAsProjectsQuery();
+    if (projectQuery.isPresent()) {
+      return applyAsQuery(projectQuery.get());
+    }
+
+    format = OutputFormat.JSON;
+    return display(null);
+  }
+
+  private Optional<String> expressAsProjectsQuery() throws BadRequestException {
+    return listProjectsFromIndex
+            && !all
+            && state != HIDDEN
+            && (isNullOrEmpty(matchPrefix)
+                || projectIndexes
+                    .getSearchIndex()
+                    .getSchema()
+                    .hasField(ProjectField.PREFIX_NAME_SPEC))
+            && isNullOrEmpty(matchRegex)
+            && type == FilterType.ALL
+            && showBranch.isEmpty()
+            && !showTree
+        ? Optional.of(toQuery())
+        : Optional.empty();
+  }
+
+  private String toQuery() throws BadRequestException {
+    // QueryProjects supports specifying matchPrefix and matchSubstring at the same time, but to
+    // keep the behavior consistent regardless of whether 'gerrit.listProjectsFromIndex' is true or
+    // false, disallow specifying both at the same time here. This way
+    // 'gerrit.listProjectsFromIndex' can be troggled without breaking any caller.
+    if (matchPrefix != null) {
+      checkMatchOptions(matchSubstring == null);
+    } else if (matchSubstring != null) {
+      checkMatchOptions(matchPrefix == null);
+    }
+
+    List<String> queries = new ArrayList<>();
+
+    if (state != null) {
+      queries.add(String.format("(state:%s)", state.name()));
+    }
+    if (!isNullOrEmpty(matchPrefix)) {
+      queries.add(String.format("prefix:%s", matchPrefix));
+    }
+    if (!isNullOrEmpty(matchSubstring)) {
+      queries.add(String.format("substring:%s", matchSubstring));
+    }
+
+    return queries.isEmpty() ? "" : Joiner.on(" AND ").join(queries);
+  }
+
+  private SortedMap<String, ProjectInfo> applyAsQuery(String query) throws BadRequestException {
+    try {
+      return queryProjectsProvider.get().withQuery(query).withStart(start).withLimit(limit).apply()
+          .stream()
+          .collect(
+              ImmutableSortedMap.toImmutableSortedMap(
+                  natural(), p -> p.name, p -> showDescription ? p : nullifyDescription(p)));
+    } catch (StorageException | MethodNotAllowedException e) {
+      logger.atWarning().withCause(e).log(
+          "Internal error while processing the query '%s' request", query);
+      throw new BadRequestException("Internal error while processing the query request");
+    }
+  }
+
+  private ProjectInfo nullifyDescription(ProjectInfo p) {
+    p.description = null;
+    return p;
+  }
+
+  private void printQueryResults(String query, PrintWriter out) throws BadRequestException {
+    try {
+      if (format.isJson()) {
+        format.newGson().toJson(applyAsQuery(query), out);
+      } else {
+        newProjectsNamesStream(query).forEach(out::println);
+      }
+      out.flush();
+    } catch (StorageException | MethodNotAllowedException e) {
+      logger.atWarning().withCause(e).log(
+          "Internal error while processing the query '%s' request", query);
+      throw new BadRequestException("Internal error while processing the query request");
+    }
+  }
+
+  private Stream<String> newProjectsNamesStream(String query)
+      throws MethodNotAllowedException, BadRequestException {
+    Stream<String> projects =
+        queryProjectsProvider.get().withQuery(query).apply().stream().map(p -> p.name).skip(start);
+    if (limit > 0) {
+      projects = projects.limit(limit);
+    }
+
+    return projects;
+  }
+
+  public void displayToStream(OutputStream displayOutputStream)
+      throws BadRequestException, PermissionBackendException {
+    PrintWriter stdout =
+        new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
+    Optional<String> projectsQuery = expressAsProjectsQuery();
+
+    if (projectsQuery.isPresent()) {
+      printQueryResults(projectsQuery.get(), stdout);
+    } else {
+      display(stdout);
+    }
+  }
+
+  @CanIgnoreReturnValue
+  @Nullable
+  public SortedMap<String, ProjectInfo> display(@Nullable PrintWriter stdout)
+      throws BadRequestException, PermissionBackendException {
+    if (all && state != null) {
+      throw new BadRequestException("'all' and 'state' may not be used together");
+    }
+    if (!isGroupVisible()) {
+      return Collections.emptySortedMap();
+    }
+
+    int foundIndex = 0;
+    int found = 0;
+    TreeMap<String, ProjectInfo> output = new TreeMap<>();
+    Map<String, String> hiddenNames = new HashMap<>();
+    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
+    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
+    final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
+    ProjectInfo lastInfo = null;
+    try {
+      Iterator<ProjectState> projectStatesIt = filter(perm).iterator();
+      while (projectStatesIt.hasNext()) {
+        ProjectState e = projectStatesIt.next();
+        Project.NameKey projectName = e.getNameKey();
+        if (e.getProject().getState() == HIDDEN && !all && state != HIDDEN) {
+          // If we can't get it from the cache, pretend it's not present.
+          // If all wasn't selected, and it's HIDDEN, pretend it's not present.
+          // If state HIDDEN wasn't selected, and it's HIDDEN, pretend it's not present.
+          continue;
+        }
+
+        if (state != null && e.getProject().getState() != state) {
+          continue;
+        }
+
+        if (groupUuid != null
+            && !e.getLocalGroups()
+                .contains(GroupReference.forGroup(groupResolver.parseId(groupUuid.get())))) {
+          continue;
+        }
+
+        if (showTree && !format.isJson()) {
+          treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true));
+          continue;
+        }
+
+        if (foundIndex++ < start) {
+          continue;
+        }
+        if (limit > 0 && ++found > limit) {
+          if (lastInfo != null) {
+            lastInfo._moreProjects = true;
+          }
+          break;
+        }
+
+        ProjectInfo info = new ProjectInfo();
+        lastInfo = info;
+
+        info.name = projectName.get();
+        if (showTree && format.isJson()) {
+          addParentProjectInfo(hiddenNames, accessibleParents, perm, e, info);
+        }
+
+        if (showDescription) {
+          info.description = emptyToNull(e.getProject().getDescription());
+        }
+        info.state = e.getProject().getState();
+
+        try {
+          if (!showBranch.isEmpty()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+
+              List<Ref> refs = retrieveBranchRefs(e, git);
+              if (!hasValidRef(refs)) {
+                continue;
+              }
+
+              addProjectBranchesInfo(info, refs);
+            }
+          } else if (!showTree && type.useMatch()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+            }
+          }
+        } catch (RepositoryNotFoundException err) {
+          // If the Git repository is gone, the project doesn't actually exist anymore.
+          continue;
+        } catch (IOException err) {
+          logger.atWarning().withCause(err).log("Unexpected error reading %s", projectName);
+          continue;
+        }
+
+        ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
+        info.webLinks = links.isEmpty() ? null : links;
+
+        if (stdout == null || format.isJson()) {
+          output.put(info.name, info);
+          continue;
+        }
+
+        if (!showBranch.isEmpty()) {
+          printProjectBranches(stdout, info);
+        }
+        stdout.print(info.name);
+
+        if (info.description != null) {
+          // We still want to list every project as one-liners, hence escaping \n.
+          stdout.print(" - " + StringUtil.escapeString(info.description));
+        }
+        stdout.print('\n');
+      }
+
+      for (ProjectInfo info : output.values()) {
+        info.id = Url.encode(info.name);
+        info.name = null;
+      }
+      if (stdout == null) {
+        return output;
+      } else if (format.isJson()) {
+        format
+            .newGson()
+            .toJson(output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
+        stdout.print('\n');
+      } else if (showTree && treeMap.size() > 0) {
+        printProjectTree(stdout, treeMap);
+      }
+      return null;
+    } finally {
+      if (stdout != null) {
+        stdout.flush();
+      }
+    }
+  }
+
+  private boolean isGroupVisible() {
+    try {
+      return groupUuid == null || groupControlFactory.controlFor(groupUuid).isVisible();
+    } catch (NoSuchGroupException ex) {
+      return false;
+    }
+  }
+
+  private void printProjectBranches(PrintWriter stdout, ProjectInfo info) {
+    for (String name : showBranch) {
+      String ref = info.branches != null ? info.branches.get(name) : null;
+      if (ref == null) {
+        // Print stub (forty '-' symbols)
+        ref = "----------------------------------------";
+      }
+      stdout.print(ref);
+      stdout.print(' ');
+    }
+  }
+
+  private void addProjectBranchesInfo(ProjectInfo info, List<Ref> refs) {
+    for (int i = 0; i < showBranch.size(); i++) {
+      Ref ref = refs.get(i);
+      if (ref != null && ref.getObjectId() != null) {
+        if (info.branches == null) {
+          info.branches = new LinkedHashMap<>();
+        }
+        info.branches.put(showBranch.get(i), ref.getObjectId().name());
+      }
+    }
+  }
+
+  private List<Ref> retrieveBranchRefs(ProjectState e, Repository git) {
+    if (!e.statePermitsRead()) {
+      return ImmutableList.of();
+    }
+
+    return getBranchRefs(e.getNameKey(), git);
+  }
+
+  private void addParentProjectInfo(
+      Map<String, String> hiddenNames,
+      Map<Project.NameKey, Boolean> accessibleParents,
+      PermissionBackend.WithUser perm,
+      ProjectState e,
+      ProjectInfo info)
+      throws PermissionBackendException {
+    ProjectState parent = Iterables.getFirst(e.parents(), null);
+    if (parent != null) {
+      if (isParentAccessible(accessibleParents, perm, parent)) {
+        info.parent = parent.getName();
+      } else {
+        info.parent = hiddenNames.get(parent.getName());
+        if (info.parent == null) {
+          info.parent = "?-" + (hiddenNames.size() + 1);
+          hiddenNames.put(parent.getName(), info.parent);
+        }
+      }
+    }
+  }
+
+  private Stream<ProjectState> filter(PermissionBackend.WithUser perm) throws BadRequestException {
+    return StreamSupport.stream(scan().spliterator(), false)
+        .map(projectCache::get)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .filter(p -> permissionCheck(p, perm));
+  }
+
+  private boolean permissionCheck(ProjectState state, PermissionBackend.WithUser perm) {
+    // Hidden projects(permitsRead = false) should only be accessible by the project owners.
+    // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
+    // be allowed for other users). Allowing project owners to access here will help them to view
+    // and update the config of hidden projects easily.
+    return perm.project(state.getNameKey())
+        .testOrFalse(
+            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG);
+  }
+
+  private boolean isParentAccessible(
+      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState state)
+      throws PermissionBackendException {
+    Project.NameKey name = state.getNameKey();
+    Boolean b = checked.get(name);
+    if (b == null) {
+      try {
+        // Hidden projects(permitsRead = false) should only be accessible by the project owners.
+        // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
+        // be allowed for other users). Allowing project owners to access here will help them to
+        // view
+        // and update the config of hidden projects easily.
+        ProjectPermission permissionToCheck =
+            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
+        perm.project(name).check(permissionToCheck);
+        b = true;
+      } catch (AuthException denied) {
+        b = false;
+      }
+      checked.put(name, b);
+    }
+    return b;
+  }
+
+  private Stream<Project.NameKey> scan() throws BadRequestException {
+    if (matchPrefix != null) {
+      checkMatchOptions(matchSubstring == null && matchRegex == null);
+      return projectCache.byName(matchPrefix).stream();
+    } else if (matchSubstring != null) {
+      checkMatchOptions(matchPrefix == null && matchRegex == null);
+      return projectCache.all().stream()
+          .filter(
+              p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
+    } else if (matchRegex != null) {
+      checkMatchOptions(matchPrefix == null && matchSubstring == null);
+      RegexListSearcher<Project.NameKey> searcher;
+      try {
+        searcher = new RegexListSearcher<>(matchRegex, Project.NameKey::get);
+      } catch (IllegalArgumentException e) {
+        throw new BadRequestException(e.getMessage());
+      }
+      return searcher.search(projectCache.all().asList());
+    } else {
+      return projectCache.all().stream();
+    }
+  }
+
+  private static void checkMatchOptions(boolean cond) throws BadRequestException {
+    if (!cond) {
+      throw new BadRequestException("specify exactly one of p/m/r");
+    }
+  }
+
+  private void printProjectTree(
+      final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
+    final NavigableSet<ProjectNode> sortedNodes = new TreeSet<>();
+
+    // Builds the inheritance tree using a list.
+    //
+    for (ProjectNode key : treeMap.values()) {
+      if (key.isAllProjects()) {
+        sortedNodes.add(key);
+        continue;
+      }
+
+      ProjectNode node = treeMap.get(key.getParentName());
+      if (node != null) {
+        node.addChild(key);
+      } else {
+        sortedNodes.add(key);
+      }
+    }
+
+    final TreeFormatter treeFormatter = new TreeFormatter(stdout);
+    treeFormatter.printTree(sortedNodes);
+    stdout.flush();
+  }
+
+  private List<Ref> getBranchRefs(Project.NameKey projectName, Repository git) {
+    Ref[] result = new Ref[showBranch.size()];
+    try {
+      PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
+      for (int i = 0; i < showBranch.size(); i++) {
+        Ref ref = git.findRef(showBranch.get(i));
+        if (ref != null && ref.getObjectId() != null) {
+          try {
+            perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
+            result[i] = ref;
+          } catch (AuthException e) {
+            continue;
+          }
+        }
+      }
+    } catch (IOException | PermissionBackendException e) {
+      // Fall through and return what is available.
+    }
+    return Arrays.asList(result);
+  }
+
+  private static boolean hasValidRef(List<Ref> refs) {
+    for (Ref ref : refs) {
+      if (ref != null) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectNode.java b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
index 816c69d..ff3f588 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectNode.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
@@ -22,7 +22,7 @@
 import java.util.NavigableSet;
 import java.util.TreeSet;
 
-/** Node of a Project in a tree formatted by {@link ListProjects}. */
+/** Node of a Project in a tree formatted by {@link ListProjectsImpl}. */
 public class ProjectNode implements TreeNode, Comparable<ProjectNode> {
   public interface Factory {
     ProjectNode create(Project project, boolean isVisible);
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index a680ec4..3883c8c6 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -25,18 +25,15 @@
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.config.GerritConfigListener;
-import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
-import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 
 public class ProjectRestApiModule extends RestApiModule {
 
   @Override
   protected void configure() {
     bind(ProjectsCollection.class);
+    bind(ListProjects.class).to(ListProjectsImpl.class);
     bind(DashboardsCollection.class);
 
     DynamicMap.mapOf(binder(), BRANCH_KIND);
@@ -49,10 +46,6 @@
     DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
 
-    DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
-    DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
-        .to(CreateProject.ValidBranchListener.class);
-
     create(PROJECT_KIND).to(CreateProject.class);
     get(PROJECT_KIND).to(GetProject.class);
     put(PROJECT_KIND).to(PutProject.class);
@@ -129,9 +122,6 @@
     delete(TAG_KIND).to(DeleteTag.class);
 
     post(PROJECT_KIND, "tags:delete").to(DeleteTags.class);
-
-    factory(RefValidationHelper.Factory.class);
-    factory(ProjectNode.Factory.class);
   }
 
   /** Separately bind batch functionality. */
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index f9602bc..8917719 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -84,7 +84,10 @@
     if (hasQuery) {
       return queryProjects.get();
     }
-    return list.get().setFormat(OutputFormat.JSON);
+
+    ListProjects listProjects = list.get();
+    listProjects.setFormat(OutputFormat.JSON);
+    return listProjects;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index b219085..dc7499d 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -25,6 +26,7 @@
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.project.ProjectJson;
@@ -47,6 +49,7 @@
   private int limit;
   private int start;
 
+  @CanIgnoreReturnValue
   @Option(
       name = "--query",
       aliases = {"-q"},
@@ -56,6 +59,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   @Option(
       name = "--limit",
       aliases = {"-n"},
@@ -66,6 +70,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   @Option(
       name = "--start",
       aliases = {"-S"},
@@ -95,10 +100,6 @@
   }
 
   public List<ProjectInfo> apply() throws BadRequestException, MethodNotAllowedException {
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
     ProjectIndex searchIndex = indexes.getSearchIndex();
     if (searchIndex == null) {
       throw new MethodNotAllowedException("no project index");
@@ -119,16 +120,21 @@
     }
 
     try {
-      QueryResult<ProjectData> result = queryProcessor.query(queryBuilder.parse(query));
+      QueryResult<ProjectData> result =
+          queryProcessor.query(
+              !Strings.isNullOrEmpty(query) ? queryBuilder.parse(query) : Predicate.any());
       List<ProjectData> pds = result.entities();
 
       ArrayList<ProjectInfo> projectInfos = Lists.newArrayListWithCapacity(pds.size());
       for (ProjectData pd : pds) {
         projectInfos.add(json.format(pd.getProject()));
       }
+      if (!projectInfos.isEmpty() && result.more()) {
+        projectInfos.get(projectInfos.size() - 1)._moreProjects = true;
+      }
       return projectInfos;
     } catch (QueryParseException e) {
-      throw new BadRequestException(e.getMessage());
+      throw new BadRequestException(e.getMessage(), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index 0471b67..7fe5e69 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -34,6 +34,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -102,8 +103,10 @@
       RevCommit mergeTip = args.mergeTip.getCurrentTip();
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-
-      PersonIdent committer = ctx.newCommitterIdent(args.caller);
+      PersonIdent committer =
+          Optional.ofNullable(toMerge.getCommitterIdent())
+              .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), args.caller))
+              .orElseGet(() -> ctx.newCommitterIdent(args.caller));
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 5f58a74..87de810 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -41,6 +41,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -167,7 +168,10 @@
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer = ctx.newCommitterIdent(args.caller);
+        PersonIdent committer =
+            Optional.ofNullable(toMerge.getCommitterIdent())
+                .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), args.caller))
+                .orElseGet(() -> ctx.newCommitterIdent(args.caller));
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index aa41d90..4e5d73f 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -164,4 +164,16 @@
   default PersonIdent newCommitterIdent(IdentifiedUser user) {
     return user.newCommitterIdent(getWhen(), getZoneId());
   }
+
+  /**
+   * Creates a committer {@link PersonIdent} for the given user. The identity will be created with
+   * the given email if the user is allowed to use it, otherwise fallback to preferred email.
+   *
+   * @param user user for which a committer {@link PersonIdent} should be created
+   * @param email committer email of the source commit
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent(String email, IdentifiedUser user) {
+    return user.newCommitterIdent(email, getWhen(), getZoneId()).orElseGet(this::newCommitterIdent);
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index e711d57..7660eeb 100644
--- a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gerrit.server.restapi.project.ListProjectsImpl;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.util.cli.Options;
@@ -28,7 +28,7 @@
     description = "List projects visible to the caller",
     runsAt = MASTER_OR_SLAVE)
 public class ListProjectsCommand extends SshCommand {
-  @Inject @Options public ListProjects impl;
+  @Inject @Options public ListProjectsImpl impl;
 
   @Override
   public void run() throws Exception {
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 6d046a3..e4e390e 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -85,7 +85,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl.SearchingChangeCacheImplModule;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
@@ -144,7 +143,6 @@
     cfg.setString(
         "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
     cfg.setEnum("auth", null, "type", AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT);
-    cfg.setString("gerrit", null, "allProjects", "Test-Projects");
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setString("gerrit", null, "canonicalWebUrl", "http://test/");
     cfg.setString("user", null, "name", "Gerrit Code Review");
@@ -152,6 +150,7 @@
     cfg.unset("cache", null, "directory");
     cfg.setBoolean("index", "lucene", "testInmemory", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
+    cfg.setInt("execution", null, "fanOutThreadPoolSize", 0);
     cfg.setBoolean("receive", null, "enableSignedPush", false);
     cfg.setString("receive", null, "certNonceSeed", "sekret");
   }
@@ -344,8 +343,8 @@
   @Provides
   @Singleton
   @FanOutExecutor
-  public ExecutorService createFanOutExecutor(WorkQueue queues) {
-    return queues.createQueue(2, "FanOut");
+  public ExecutorService createFanOutExecutor() {
+    return newDirectExecutorService();
   }
 
   @Provides
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index b55ddec..d8c2aae 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -31,9 +31,13 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -64,7 +68,7 @@
   private static final String DESTINATION_BRANCH = "destBranch";
 
   private static final String ADDED_FILE_NAME = "a_new_file.txt";
-  private static final String ADDED_FILE_CONTENT = "First added line\nSecond added line\n";
+  private static final String ADDED_FILE_CONTENT = "First added line\nSecond added line";
   private static final String ADDED_FILE_DIFF =
       "diff --git a/a_new_file.txt b/a_new_file.txt\n"
           + "new file mode 100644\n"
@@ -72,10 +76,13 @@
           + "+++ b/a_new_file.txt\n"
           + "@@ -0,0 +1,2 @@\n"
           + "+First added line\n"
-          + "+Second added line\n";
+          + "+Second added line\n"
+          + "\\ No newline at end of file\n";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
 
   @Test
   public void applyAddedFilePatch_success() throws Exception {
@@ -88,6 +95,27 @@
     assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
   }
 
+  @Test
+  public void applyAddedFilePatchAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Apply patch
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.responseFormatOptions = ImmutableList.of(CURRENT_REVISION, CURRENT_COMMIT);
+    ChangeInfo result = gApi.changes().id(change.get()).applyPatch(in);
+
+    assertThat(result.getCurrentRevision().commit.committer.email).isEqualTo(emailOne);
+  }
+
   private static final String MODIFIED_FILE_NAME = "modified_file.txt";
   private static final String MODIFIED_FILE_ORIGINAL_CONTENT =
       "First original line\nSecond original line";
@@ -505,6 +533,38 @@
   }
 
   @Test
+  public void commitMessage_providedMessageWithCorrectChangeId() throws Exception {
+    initDestBranch();
+    String originalChangeId =
+        gApi.changes()
+            .create(new ChangeInput(project.get(), DESTINATION_BRANCH, "Default commit message"))
+            .info()
+            .changeId;
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.commitMessage = "custom commit message\n\nChange-Id: " + originalChangeId + "\n";
+
+    ChangeInfo result = gApi.changes().id(originalChangeId).applyPatch(in);
+
+    ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message).isEqualTo(in.commitMessage);
+  }
+
+  @Test
+  public void commitMessage_providedMessageWithWrongChangeId() throws Exception {
+    initDestBranch();
+    String originalChangeId =
+        gApi.changes()
+            .create(new ChangeInput(project.get(), DESTINATION_BRANCH, "Default commit message"))
+            .info()
+            .changeId;
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.commitMessage = "custom commit message\n\nChange-Id: " + "I1234567890" + "\n";
+
+    assertThrows(
+        ResourceConflictException.class, () -> gApi.changes().id(originalChangeId).applyPatch(in));
+  }
+
+  @Test
   public void commitMessage_defaultMessageAndPatchHeader() throws Exception {
     initDestBranch();
     ApplyPatchPatchSetInput in = buildInput("Patch header\n" + ADDED_FILE_DIFF);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 7fe7635..c920843 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -88,6 +88,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.server.change.CommentsUtil;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -159,7 +160,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -236,6 +236,14 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
   @Inject private AccountControl.Factory accountControlFactory;
+  @Inject private ChangeOperations changeOperations;
+
+  public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.SUBMIT_REQUIREMENTS,
+          ListChangesOption.STAR);
 
   @Inject
   @Named("diff_intraline")
@@ -506,6 +514,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.reviewers).isNotEmpty();
     ChangeInfo info = gApi.changes().id(changeId).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
@@ -2643,11 +2652,27 @@
 
   @Test
   public void queryChangesLimit() throws Exception {
-    createChange();
-    PushOneCommit.Result r2 = createChange();
-    List<ChangeInfo> results = gApi.changes().query().withLimit(1).get();
-    assertThat(results).hasSize(1);
-    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r2.getChangeId());
+    for (int i = 0; i < 3; i++) {
+      createChange();
+    }
+    List<ChangeInfo> resultsLimited = gApi.changes().query().withLimit(1).get();
+    List<ChangeInfo> resultsUnlimited = gApi.changes().query().get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited.size()).isAtLeast(3);
+  }
+
+  @Test
+  @GerritConfig(name = "index.defaultLimit", value = "2")
+  public void queryChangesLimitDefault() throws Exception {
+    for (int i = 0; i < 4; i++) {
+      createChange();
+    }
+    List<ChangeInfo> resultsLimited = gApi.changes().query().withLimit(1).get();
+    List<ChangeInfo> resultsUnlimited = gApi.changes().query().get();
+    List<ChangeInfo> resultsLimitedAboveDefault = gApi.changes().query().withLimit(3).get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited).hasSize(2);
+    assertThat(resultsLimitedAboveDefault).hasSize(3);
   }
 
   @Test
@@ -2846,6 +2871,23 @@
   }
 
   @Test
+  @GerritConfig(name = "change.topicLimit", value = "3")
+  public void topicSizeLimit() throws Exception {
+    for (int i = 0; i < 3; i++) {
+      createChangeWithTopic(testRepo, "limitedTopic", "message", "a.txt", "content\n");
+    }
+    PushOneCommit.Result rLimited =
+        pushFactory
+            .create(user.newIdent(), testRepo)
+            .to("refs/for/master%topic=" + name("limitedTopic"));
+    rLimited.assertErrorStatus("topicLimit");
+
+    PushOneCommit.Result rOther =
+        createChangeWithTopic(testRepo, "otherTopic", "message", "a.txt", "content\n");
+    assertThat(gApi.changes().id(rOther.getChangeId()).topic()).contains("otherTopic");
+  }
+
+  @Test
   public void editTopicWithoutPermissionNotAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
@@ -3096,7 +3138,7 @@
               gApi.changes()
                   .query()
                   .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-                  .withOptions(IndexPreloadingUtil.DASHBOARD_OPTIONS)
+                  .withOptions(DASHBOARD_OPTIONS)
                   .get())
           .hasSize(2);
     }
@@ -3860,6 +3902,28 @@
   }
 
   @Test
+  public void changeCommitMessageAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Change commit message
+    ChangeInfo changeInfo = gApi.changes().id(change.get()).get();
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeInfo.changeId);
+    gApi.changes().id(change.get()).setMessage(msg);
+
+    assertThat(gApi.changes().id(change.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void changeCommitMessageFromChangeIdToLinkFooter() throws Exception {
     PushOneCommit.Result r = createChange();
     r.assertOkStatus();
@@ -4525,6 +4589,18 @@
     testEmailSubjectContainsChangeSizeBucket(1000, "XL");
   }
 
+  @Test
+  public void requestFormattedChangeInReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    ReviewInput in = ReviewInput.approve().reviewer(user.email()).label(LabelId.CODE_REVIEW, 1);
+    in.responseFormatOptions = ImmutableList.of(ListChangesOption.CURRENT_REVISION);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
+    assertThat(result.changeInfo).isNotNull();
+    assertThat(result.changeInfo.currentRevision).isNotNull();
+  }
+
   private void testEmailSubjectContainsChangeSizeBucket(
       int numberOfLines, String expectedSizeBucket) throws Exception {
     String change;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
index 5213249..502f286 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -25,14 +25,21 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -46,6 +53,10 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.util.List;
@@ -58,6 +69,8 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
 
   @Before
   public void setUp() {
@@ -150,6 +163,35 @@
   }
 
   @Test
+  public void createMergePatchSetAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+    String branch = "dev";
+    createBranch(BranchNameKey.create(project, branch));
+
+    // Create a change for master branch
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Push a commit to dev branch
+    createChange("refs/heads/dev");
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Create merge patch-set
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = branch;
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    gApi.changes().id(change.get()).createMergePatchSet(in);
+
+    assertThat(gApi.changes().id(change.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void createMergePatchSet_Conflict() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch(BranchNameKey.create(project, "dev"));
@@ -361,6 +403,43 @@
   }
 
   @Test
+  public void createMergePatchSetWithValidationOption() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // advance master branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch(BranchNameKey.create(project, "foo"));
@@ -653,4 +732,15 @@
           event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
     }
   }
+
+  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/RebaseChainOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
index 785186d..7d1ddfc 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
@@ -209,6 +209,76 @@
   }
 
   @Test
+  public void rebaseChainOnBehalfOfUploaderAfterUpdatingPreferredEmailForUploader()
+      throws Exception {
+    // Create a chain of changes for being rebased
+    String uploaderEmailOne = "uploader1@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmailOne).create();
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased1)
+            .owner(uploader)
+            .create();
+
+    Change.Id changeToBeRebased3 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased2)
+            .owner(uploader)
+            .create();
+
+    // Change preferred email for the uploader
+    String uploaderEmailTwo = "uploader2@example.com";
+    accountOperations.account(uploader).forUpdate().preferredEmail(uploaderEmailTwo).update();
+
+    // Create, approve and submit the change that will be the new base for the chain that will be
+    // rebased
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the chain on behalf of the uploader through changeToBeRebased3
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased3.get()).rebaseChain(rebaseInput);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased1.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased2.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased3.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+  }
+
+  @Test
   public void rebaseChainOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
     allowPermissionToAllUsers(Permission.REBASE);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index ade7dc6..d9b079a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -36,10 +36,12 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestMetricMaker;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
@@ -97,6 +99,7 @@
     @Inject protected ProjectOperations projectOperations;
     @Inject protected ExtensionRegistry extensionRegistry;
     @Inject protected TestMetricMaker testMetricMaker;
+    @Inject protected AccountOperations accountOperations;
 
     @FunctionalInterface
     protected interface RebaseCall {
@@ -214,6 +217,30 @@
     }
 
     @Test
+    public void rebaseChangeAfterUpdatingPreferredEmail() throws Exception {
+      String emailOne = "email1@example.com";
+      Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+      // Create two changes both with the same parent
+      Change.Id c1 = changeOperations.newChange().project(project).owner(testUser).create();
+      Change.Id c2 = changeOperations.newChange().project(project).owner(testUser).create();
+
+      // Approve and submit the first change
+      gApi.changes().id(c1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(c1.get()).current().submit();
+
+      // Change preferred email for the user
+      String emailTwo = "email2@example.com";
+      accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+      requestScopeOperations.setApiUser(testUser);
+
+      // Rebase the second change
+      gApi.changes().id(c2.get()).rebase();
+      assertThat(gApi.changes().id(c2.get()).get().getCurrentRevision().commit.committer.email)
+          .isEqualTo(emailOne);
+    }
+
+    @Test
     public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
       ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
@@ -434,6 +461,12 @@
       String changeId = r2.getChangeId();
       requestScopeOperations.setApiUser(user.id());
       rebaseCall.call(changeId);
+
+      // Verify that the committer has been updated
+      GitPerson committer =
+          gApi.changes().id(r2.getChangeId()).get().getCurrentRevision().commit.committer;
+      assertThat(committer.name).isEqualTo(user.fullName());
+      assertThat(committer.email).isEqualTo(user.email());
     }
 
     @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
index 0fb8a82..968c1f7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -251,6 +251,41 @@
   }
 
   @Test
+  public void rebaseChangeOnBehalfOfUploaderAfterUpdatingPreferredEmailForUploader()
+      throws Exception {
+    String uploaderEmailOne = "uploader1@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmailOne).create();
+
+    // Create two changes both with the same parent
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Change preferred email for the uploader
+    String uploaderEmailTwo = "uploader2@example.com";
+    accountOperations.account(uploader).forUpdate().preferredEmail(uploaderEmailTwo).update();
+
+    // Rebase the second change on behalf of the uploader
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+  }
+
+  @Test
   public void rebaseChangeOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
     allowPermissionToAllUsers(Permission.REBASE);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index ab2f358..d1e6bcba 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -70,7 +70,6 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.testing.TestLabels;
@@ -103,6 +102,13 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
 
+  private static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.SUBMIT_REQUIREMENTS,
+          ListChangesOption.STAR);
+
   @Test
   public void submitRecords() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -2952,7 +2958,7 @@
               .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
               .withOptions(
                   new ImmutableSet.Builder<ListChangesOption>()
-                      .addAll(IndexPreloadingUtil.DASHBOARD_OPTIONS)
+                      .addAll(DASHBOARD_OPTIONS)
                       .add(ListChangesOption.SUBMIT_REQUIREMENTS)
                       .build())
               .get();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 5a024cc..84a4a40 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -29,8 +29,13 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -58,6 +63,9 @@
 @NoHttpd
 public class CommitIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void getCommitInfo() throws Exception {
@@ -190,6 +198,40 @@
   }
 
   @Test
+  public void cherryPickCommitAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create target branch to cherry-pick to
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    // Create change to cherry-pick
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Cherry-pick the change
+    String commit = gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.commit;
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message = "cherry-pick to foo branch";
+    ChangeInfo cherryPickResult =
+        gApi.projects().name(project.get()).commit(commit).cherryPick(input).get();
+    assertThat(cherryPickResult.getCurrentRevision().commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void cherryPickWithoutMessageOtherBranch() throws Exception {
     String destBranch = "foo";
     createBranch(BranchNameKey.create(project, destBranch));
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index f997c77..a93c0a5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -1209,6 +1209,31 @@
         .isNull();
   }
 
+  @Test
+  public void queryProjectsLimit() throws Exception {
+    for (int i = 0; i < 3; i++) {
+      projectOperations.newProject().create();
+    }
+    List<ProjectInfo> resultsLimited = gApi.projects().query().withLimit(1).get();
+    List<ProjectInfo> resultsUnlimited = gApi.projects().query().get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited.size()).isAtLeast(3);
+  }
+
+  @Test
+  @GerritConfig(name = "index.defaultLimit", value = "2")
+  public void queryProjectsLimitDefault() throws Exception {
+    for (int i = 0; i < 4; i++) {
+      projectOperations.newProject().create();
+    }
+    List<ProjectInfo> resultsLimited = gApi.projects().query().withLimit(1).get();
+    List<ProjectInfo> resultsUnlimited = gApi.projects().query().get();
+    List<ProjectInfo> resultsLimitedAboveDefault = gApi.projects().query().withLimit(3).get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited).hasSize(2);
+    assertThat(resultsLimitedAboveDefault).hasSize(3);
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     CommentLinkInfo info = new CommentLinkInfo();
     info.name = name;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
index 7c0b713..b9ef0bf 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
@@ -25,7 +25,12 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -52,6 +57,9 @@
 
 public class ApplyProvidedFixIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String FILE_NAME = "file_to_fix.txt";
   private static final String FILE_NAME2 = "another_file_to_fix.txt";
@@ -95,6 +103,35 @@
   }
 
   @Test
+  public void applyProvidedFixAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content(FILE_CONTENT)
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Apply fix
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(change.get()).current().applyFix(applyProvidedFixInput);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void applyProvidedFixRestAPItestForASimpleFix() throws Exception {
     ApplyProvidedFixInput applyProvidedFixInput =
         createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index cc7712b..b3db99f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -49,6 +49,8 @@
 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;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
@@ -133,6 +135,8 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
 
   @Test
   public void reviewTriplet() throws Exception {
@@ -351,6 +355,40 @@
   }
 
   @Test
+  public void cherryPickAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create target branch to cherry-pick to
+    String branch = "foo";
+    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+
+    // Create change to cherry-pick
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Cherry-pick the change
+    String commit = gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.commit;
+    CherryPickInput input = new CherryPickInput();
+    input.destination = branch;
+    input.message = "cherry-pick to foo branch";
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId.get()).revision(commit).cherryPick(input).get();
+    assertThat(changeInfo.getCurrentRevision().commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void cherryPickWithoutMessage() throws Exception {
     String branch = "foo";
 
@@ -1718,6 +1756,34 @@
   }
 
   @Test
+  @GerritConfig(name = "change.maxFileSizeDownload", value = "10")
+  public void content_maxFileSizeDownload() throws Exception {
+    Map<String, String> files =
+        ImmutableMap.of("dir/file1.txt", " 9 bytes ", "dir/file2.txt", "11 bytes xx");
+    PushOneCommit.Result result =
+        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
+    result.assertOkStatus();
+
+    // 9 bytes should be fine, because the limit is 10 bytes.
+    assertContent(result, "dir/file1.txt", " 9 bytes ");
+
+    // 11 bytes should throw, because the limit is 10 bytes.
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(result.getChangeId())
+                    .revision(result.getCommit().name())
+                    .file("dir/file2.txt")
+                    .content());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "File too big. File size: 11 bytes. Configured 'maxFileSizeDownload' limit: 10 bytes.");
+  }
+
+  @Test
   public void patchsetLevelContentDoesNotExist() throws Exception {
     PushOneCommit.Result change = createChange();
     assertThrows(
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 1363ce7..b31d35c 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -34,7 +34,10 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
@@ -73,6 +76,8 @@
 public class RobotCommentsIT extends AbstractDaemonTest {
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
   private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
@@ -755,6 +760,46 @@
   }
 
   @Test
+  public void applyStoredFixAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content(FILE_CONTENT)
+            .owner(testUser)
+            .create();
+
+    // Add Robot Comment to the change
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+    testCommentHelper.addRobotComment(project + "~" + change.get(), withFixRobotCommentInput);
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Fetch Fix ID
+    List<RobotCommentInfo> robotCommentInfoList =
+        gApi.changes().id(change.get()).current().robotCommentsAsList();
+
+    List<String> fixIds = getFixIds(robotCommentInfoList);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    // Apply fix
+    gApi.changes().id(change.get()).current().applyFix(fixId);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void storedFixSpanningMultipleLinesCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content\n5";
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 4168164..9c691ae 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -37,9 +37,13 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
@@ -112,6 +116,8 @@
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
 
   private String changeId;
   private String changeId2;
@@ -265,6 +271,30 @@
   }
 
   @Test
+  public void rebaseEditAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Create edit
+    createEmptyEditFor(project + "~" + change.get());
+    // Add new patch-set to change
+    changeOperations.change(change).newPatchset().create();
+    // Rebase Edit
+    gApi.changes().id(change.get()).edit().rebase();
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void rebaseEditRest() throws Exception {
     PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
     createEmptyEditFor(changeId2);
@@ -312,6 +342,33 @@
   }
 
   @Test
+  public void updateExistingFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Modify file
+    gApi.changes().id(change.get()).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void updateCommitMessageByEditingMagicCommitMsgFile() throws Exception {
     createEmptyEditFor(changeId);
     String updatedCommitMsg = "Foo Bar\n\nChange-Id: " + changeId + "\n";
@@ -455,6 +512,28 @@
   }
 
   @Test
+  public void updateMessageAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Update commit message
+    ChangeInfo changeInfo = gApi.changes().id(change.get()).get();
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeInfo.changeId);
+    gApi.changes().id(change.get()).edit().modifyCommitMessage(msg);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void updateMessageRest() throws Exception {
     adminRestSession.get(urlEditMessage(changeId, false)).assertNotFound();
     EditMessage.Input in = new EditMessage.Input();
@@ -601,6 +680,33 @@
   }
 
   @Test
+  public void deleteExistingFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Delete file
+    gApi.changes().id(change.get()).edit().deleteFile(FILE_NAME);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void renameExistingFile() throws Exception {
     createEmptyEditFor(changeId);
     gApi.changes().id(changeId).edit().renameFile(FILE_NAME, FILE_NAME3);
@@ -609,6 +715,33 @@
   }
 
   @Test
+  public void renameExistingFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Rename file
+    gApi.changes().id(change.get()).edit().renameFile(FILE_NAME, FILE_NAME3);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void renameExistingFileToInvalidPath() throws Exception {
     createEmptyEditFor(changeId);
     BadRequestException badRequest =
@@ -644,6 +777,33 @@
   }
 
   @Test
+  public void restoreFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Restore file to the state in the parent of change
+    gApi.changes().id(change.get()).edit().restoreFile(FILE_NAME);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void revertChanges() throws Exception {
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 6378ce0..2ab054b 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -535,6 +535,16 @@
   }
 
   @Test
+  @GerritConfig(name = "change.topicLimit", value = "3")
+  public void pushForMasterWithTopicExceedsSizeLimitFails() throws Exception {
+    pushTo("refs/for/master%topic=limited").assertOkStatus();
+    pushTo("refs/for/master%topic=limited").assertOkStatus();
+    pushTo("refs/for/master%topic=limited").assertOkStatus();
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=limited");
+    r.assertErrorStatus("topicLimit");
+  }
+
+  @Test
   public void pushForMasterWithNotify() throws Exception {
     // create a user that watches the project
     TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
diff --git a/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java b/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
index 39f1e8d..f199b55 100644
--- a/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.DynamicOptions;
-import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gerrit.server.restapi.project.ListProjectsImpl;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
@@ -48,7 +48,7 @@
   protected static class ListProjectsBeanListener implements DynamicOptions.BeanParseListener {
     @Override
     public void onBeanParseStart(String plugin, Object bean) {
-      ListProjects listProjects = (ListProjects) bean;
+      ListProjectsImpl listProjects = (ListProjectsImpl) bean;
       listProjects.setLimit(1);
     }
 
@@ -60,7 +60,7 @@
     @Override
     public void configure() {
       bind(DynamicOptions.DynamicBean.class)
-          .annotatedWith(Exports.named(ListProjects.class))
+          .annotatedWith(Exports.named(ListProjectsImpl.class))
           .to(ListProjectsBeanListener.class);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ListProjectOptionsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ListProjectOptionsRestApiBindingsIT.java
new file mode 100644
index 0000000..1d209e1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ListProjectOptionsRestApiBindingsIT.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.binding;
+
+import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
+import static org.apache.http.HttpStatus.SC_BAD_REQUEST;
+import static org.apache.http.HttpStatus.SC_OK;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import org.junit.Test;
+
+public class ListProjectOptionsRestApiBindingsIT extends AbstractDaemonTest {
+  private static final ImmutableList<RestCall> LIST_PROJECTS_WITH_OPTIONS =
+      ImmutableList.of(
+          // =========================
+          // === Supported options ===
+          // =========================
+          get200OK("/projects/?show-branch=refs/heads/master"),
+          get200OK("/projects/?b=refs/heads/master"),
+          get200OK("/projects/?format=TEXT"),
+          get200OK("/projects/?format=JSON"),
+          get200OK("/projects/?format=JSON_COMPACT"),
+          get200OK("/projects/?tree"),
+          get200OK("/projects/?tree=true"),
+          get200OK("/projects/?tree=false"),
+          get200OK("/projects/?t"),
+          get200OK("/projects/?t=true"),
+          get200OK("/projects/?t=false"),
+          get200OK("/projects/?type=ALL"),
+          get200OK("/projects/?type=CODE"),
+          get200OK("/projects/?type=PERMISSIONS"),
+          get200OK("/projects/?description"),
+          get200OK("/projects/?description=true"),
+          get200OK("/projects/?description=false"),
+          get200OK("/projects/?d"),
+          get200OK("/projects/?d=true"),
+          get200OK("/projects/?d=false"),
+          get200OK("/projects/?all"),
+          get200OK("/projects/?all=true"),
+          get200OK("/projects/?all=false"),
+          get200OK("/projects/?state=ACTIVE"),
+          get200OK("/projects/?state=READ_ONLY"),
+          get200OK("/projects/?state=HIDDEN"),
+          get200OK("/projects/?limit=10"),
+          get200OK("/projects/?n=10"),
+          get200OK("/projects/?start=10"),
+          get200OK("/projects/?S=10"),
+          get200OK("/projects/?prefix=my-prefix"),
+          get200OK("/projects/?p=my-prefix"),
+          get200OK("/projects/?match=my-match"),
+          get200OK("/projects/?m=my-match"),
+          get200OK("/projects/?r=my-regex"),
+          get200OK("/projects/?has-acl-for=" + SystemGroupBackend.ANONYMOUS_USERS.get()),
+
+          // ===========================
+          // === Unsupported options ===
+          // ===========================
+          get400BadRequest("/projects/?unknown", "\"--unknown\" is not a valid option"),
+          get400BadRequest("/projects/?unknown", "\"--unknown\" is not a valid option"),
+          get400BadRequest(
+              "/projects/?format=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--format\""),
+          get400BadRequest("/projects/?tree=UNKNOWN", "invalid boolean \"tree=UNKNOWN\""),
+          get400BadRequest("/projects/?t=UNKNOWN", "invalid boolean \"t=UNKNOWN\""),
+          get400BadRequest(
+              "/projects/?type=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--type\""),
+          get400BadRequest(
+              "/projects/?description=UNKNOWN", "invalid boolean \"description=UNKNOWN\""),
+          get400BadRequest("/projects/?d=UNKNOWN", "invalid boolean \"d=UNKNOWN\""),
+          get400BadRequest("/projects/?all=UNKNOWN", "invalid boolean \"all=UNKNOWN\""),
+          get400BadRequest(
+              "/projects/?state=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--state\""),
+          get400BadRequest("/projects/?n=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"-n\""),
+          get400BadRequest(
+              "/projects/?start=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--start\""),
+          get400BadRequest("/projects/?S=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"-S\""),
+          get400BadRequest("/projects/?has-acl-for=UNKNOWN", "Group \"UNKNOWN\" does not exist"));
+
+  private static RestCall get200OK(String uriFormat) {
+    return RestCall.builder(GET, uriFormat).expectedResponseCode(SC_OK).build();
+  }
+
+  private static RestCall get400BadRequest(String uriFormat, String expectedMessage) {
+    return RestCall.builder(GET, uriFormat)
+        .expectedResponseCode(SC_BAD_REQUEST)
+        .expectedMessage(expectedMessage)
+        .build();
+  }
+
+  @Test
+  public void listProjectsWithOptions() throws Exception {
+    RestApiCallHelper.execute(adminRestSession, LIST_PROJECTS_WITH_OPTIONS);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 0550cb9..f8eccb5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseSystemTime;
+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;
@@ -192,6 +193,16 @@
   }
 
   @Test
+  @GerritConfig(name = "change.topicLimit", value = "3")
+  public void createNewChange_exceedsTopicLimit() throws Exception {
+    assertCreateSucceeds(newChangeWithTopic("limited"));
+    assertCreateSucceeds(newChangeWithTopic("limited"));
+    assertCreateSucceeds(newChangeWithTopic("limited"));
+    ChangeInput ci = newChangeWithTopic("limited");
+    assertCreateFails(ci, BadRequestException.class, "topicLimit");
+  }
+
+  @Test
   public void createNewChange() throws Exception {
     ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
     assertThat(info.revisions.get(info.currentRevision).commit.message)
@@ -1319,6 +1330,12 @@
     return in;
   }
 
+  private ChangeInput newChangeWithTopic(String topic) {
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.topic = topic;
+    return in;
+  }
+
   private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().create(in).get();
     validateCreateSucceeds(in, out);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
index 03722e6..3a1d909 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
@@ -27,9 +27,8 @@
 import com.google.common.collect.Iterables;
 import com.google.common.truth.MapSubject;
 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.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -38,19 +37,29 @@
 import com.google.inject.Inject;
 import org.junit.Test;
 
-@NoHttpd
-@UseClockStep
 public class CustomKeyedValuesIT extends AbstractDaemonTest {
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void getNoCustomKeyedValues() throws Exception {
-    // Get on a change with no hashtags returns an empty list.
+    // Get on a change with no custom keyed values returns an empty list.
     PushOneCommit.Result r = createChange();
     assertThatGet(r).isEmpty();
   }
 
   @Test
+  public void parsesInputCorrectly() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String endpoint = "/changes/" + r.getChangeId() + "/custom_keyed_values";
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key", "value");
+    RestResponse response = adminRestSession.post(endpoint, input);
+    response.assertOK();
+
+    assertThatGet(r).containsExactly("key", "value");
+  }
+
+  @Test
   public void addSingleCustomKeyedValue() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeMessageInfo last = getLastMessage(r);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index c712b14..37684de 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -16,8 +16,11 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.Comparator.comparing;
 
 import com.google.common.collect.Iterables;
@@ -25,13 +28,20 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -39,6 +49,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
@@ -49,6 +60,9 @@
 public class SubmitByCherryPickIT extends AbstractSubmit {
   @Inject private ProjectOperations projectOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -94,6 +108,46 @@
   }
 
   @Test
+  public void submitWithCherryPickAfterUpdatingPreferredEmail() throws Throwable {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Add permissions to apply label "Code-Review": 2 and submit
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Create change to submit
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Approve and submit the change
+    RevisionApi revision = gApi.changes().id(changeId.get()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+    assertThat(gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
     ChangeMessageModifier link =
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index d58ad11..80fbe99 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -16,7 +16,10 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Comparator.comparing;
 
@@ -27,20 +30,28 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
@@ -51,6 +62,9 @@
   @Inject private DynamicItem<UrlFormatter> urlFormatter;
   @Inject private ProjectOperations projectOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -96,6 +110,46 @@
   }
 
   @Test
+  public void submitByRebaseAfterUpdatingPreferredEmail() throws Throwable {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Add permissions to apply label "Code-Review": 2 and submit
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Create change to submit
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Approve and submit the change
+    RevisionApi revision = gApi.changes().id(changeId.get()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+    assertThat(gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void rebaseInvokesChangeMessageModifiers() throws Throwable {
     ChangeMessageModifier modifier1 =
         (msg, orig, tip, dest) -> msg + "This-change-before-rebase: " + orig.name() + "\n";
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index e69f781..1df3f3e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -43,7 +43,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gerrit.server.restapi.project.ListProjectsImpl;
 import com.google.gson.Gson;
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
@@ -59,7 +59,7 @@
 public class ListProjectsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private ListProjects listProjects;
+  @Inject private ListProjectsImpl listProjects;
 
   @Test
   public void listProjects() throws Exception {
@@ -140,6 +140,33 @@
   }
 
   @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "true")
+  public void listProjectsSetsMoreProjectsIfLimited_indexEnabled() throws Exception {
+    testListProjectsSetsMoreProjectsIfLimited();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "false")
+  public void listProjectsSetsMoreProjectsIfLimited_indexDisabled() throws Exception {
+    testListProjectsSetsMoreProjectsIfLimited();
+  }
+
+  private void testListProjectsSetsMoreProjectsIfLimited() throws Exception {
+    for (int i = 0; i < 3; i++) {
+      projectOperations.newProject().name("prefix-" + i).create();
+    }
+
+    List<ProjectInfo> result = gApi.projects().list().withPrefix("prefix").get();
+    assertThat(Iterables.getLast(result)._moreProjects).isNull();
+
+    result = gApi.projects().list().withPrefix("prefix").withLimit(Integer.MAX_VALUE).get();
+    assertThat(Iterables.getLast(result)._moreProjects).isNull();
+
+    result = gApi.projects().list().withPrefix("prefix").withLimit(2).get();
+    assertThat(Iterables.getLast(result)._moreProjects).isTrue();
+  }
+
+  @Test
   public void listProjectsToOutputStream() throws Exception {
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
@@ -159,7 +186,8 @@
 
   @Test
   public void listProjectsAsJsonMultilineToOutputStream() throws Exception {
-    listProjectsAsJsonToOutputStream(OutputFormat.JSON);
+    String jsonOutput = listProjectsAsJsonToOutputStream(OutputFormat.JSON);
+    assertThat(jsonOutput).contains("\n");
   }
 
   @Test
@@ -198,7 +226,18 @@
   }
 
   @Test
-  public void listProjectsWithPrefix() throws Exception {
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "true")
+  public void listProjectsWithPrefix_indexEnabled() throws Exception {
+    testListProjectsWithPrefix();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "false")
+  public void listProjectsWithPrefix_indexDisabled() throws Exception {
+    testListProjectsWithPrefix();
+  }
+
+  private void testListProjectsWithPrefix() throws Exception {
     Project.NameKey someProject = projectOperations.newProject().name("listtest-p1").create();
     Project.NameKey someOtherProject = projectOperations.newProject().name("listtest-p2").create();
     projectOperations.newProject().name("other-prefix-project").create();
@@ -248,7 +287,18 @@
   }
 
   @Test
-  public void listProjectsWithSubstring() throws Exception {
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "true")
+  public void listProjectsWithSubstring_indexEnabled() throws Exception {
+    testListProjectsWithSubstring();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "false")
+  public void listProjectsWithSubstring_indexDisabled() throws Exception {
+    testListProjectsWithSubstring();
+  }
+
+  private void testListProjectsWithSubstring() throws Exception {
     Project.NameKey someProject = projectOperations.newProject().name("some-project").create();
     Project.NameKey someOtherProject =
         projectOperations.newProject().name("some-other-project").create();
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index cc1ee00..5cb7feb 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -115,9 +115,7 @@
     when(gerritApi.config()).thenReturn(configApi);
 
     assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
-        .containsAtLeast(
-            "defaultChangeDetailHex", "1916314",
-            "changeRequestsPath", "changes/project~123");
+        .containsAtLeast("changeRequestsPath", "changes/project~123");
   }
 
   private static SanitizedContent ordain(String s) {
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 4821f20..6e37f61 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -77,6 +77,7 @@
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib:protobuf",
+        "//lib:roaringbitmap",
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
index 4a3c930..0863a2c 100644
--- a/javatests/com/google/gerrit/server/git/TagSetTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -31,9 +31,8 @@
 import com.google.gerrit.server.git.TagSet.CachedRef;
 import com.google.gerrit.server.git.TagSet.Tag;
 import com.google.inject.TypeLiteral;
+import com.google.protobuf.ByteString;
 import java.lang.reflect.Type;
-import java.util.Arrays;
-import java.util.BitSet;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.Map;
@@ -41,6 +40,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.junit.Test;
+import org.roaringbitmap.RoaringBitmap;
 
 public class TagSetTest {
   @Test
@@ -55,10 +55,12 @@
     ObjectIdOwnerMap<Tag> tags = new ObjectIdOwnerMap<>();
     tags.add(
         new Tag(
-            ObjectId.fromString("cccccccccccccccccccccccccccccccccccccccc"), newBitSet(1, 3, 5)));
+            ObjectId.fromString("cccccccccccccccccccccccccccccccccccccccc"),
+            RoaringBitmap.bitmapOf(1, 3, 5)));
     tags.add(
         new Tag(
-            ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"), newBitSet(2, 4, 6)));
+            ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"),
+            RoaringBitmap.bitmapOf(2, 4, 6)));
     TagSet tagSet = new TagSet(Project.nameKey("project"), refs, tags);
 
     TagSetProto proto = tagSet.toProto();
@@ -91,7 +93,9 @@
                             byteString(
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc,
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc))
-                        .setFlags(byteString(0x2a))
+                        .setFlags(
+                            ByteString.copyFromUtf8(
+                                ":0\000\000\001\000\000\000\000\000\002\000\020\000\000\000\001\000\003\000\005\000"))
                         .build())
                 .addTag(
                     TagProto.newBuilder()
@@ -99,7 +103,9 @@
                             byteString(
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd,
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd))
-                        .setFlags(byteString(0x54))
+                        .setFlags(
+                            ByteString.copyFromUtf8(
+                                ":0\000\000\001\000\000\000\000\000\002\000\020\000\000\000\002\000\004\000\006\000"))
                         .build())
                 .build());
 
@@ -132,7 +138,7 @@
     assertThatSerializedClass(Tag.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
-                .put("refFlags", BitSet.class)
+                .put("refFlags", RoaringBitmap.class)
                 .put("next", ObjectIdOwnerMap.Entry.class)
                 .put("w1", int.class)
                 .put("w2", int.class)
@@ -181,10 +187,4 @@
         .map(Tag::name)
         .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.naturalOrder()));
   }
-
-  private BitSet newBitSet(int... bits) {
-    BitSet result = new BitSet();
-    Arrays.stream(bits).forEach(result::set);
-    return result;
-  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 6e5fc9b..a4846be 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -29,6 +29,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.mockito.Mockito.mock;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -59,6 +60,7 @@
 import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -78,6 +80,7 @@
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeNotesTest extends AbstractChangeNotesTest {
@@ -85,6 +88,13 @@
 
   @Inject private DraftCommentsReader draftCommentsReader;
 
+  private TopicValidator topicValidator;
+
+  @Before
+  public void setUp() throws Exception {
+    topicValidator = mock(TopicValidator.class);
+  }
+
   @Test
   public void tagChangeMessage() throws Exception {
     String tag = "jenkins";
@@ -1304,7 +1314,7 @@
 
     ChangeUpdate update = newUpdate(c, changeOwner);
     // Make sure unrelevent update does not set mergedOn.
-    update.setTopic("topic");
+    update.setTopic("topic", topicValidator);
     update.commit();
     assertThat(newNotes(c).getMergedOn()).isEmpty();
   }
@@ -1631,14 +1641,14 @@
     // set topic
     String topic = "myTopic";
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic(topic);
+    update.setTopic(topic, topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isEqualTo(topic);
 
     // clear topic by setting empty string
     update = newUpdate(c, changeOwner);
-    update.setTopic("");
+    update.setTopic("", topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isNull();
@@ -1646,21 +1656,21 @@
     // set other topic
     topic = "otherTopic";
     update = newUpdate(c, changeOwner);
-    update.setTopic(topic);
+    update.setTopic(topic, topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isEqualTo(topic);
 
     // clear topic by setting null
     update = newUpdate(c, changeOwner);
-    update.setTopic(null);
+    update.setTopic(null, topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isNull();
 
     // check invalid topic
     ChangeUpdate failingUpdate = newUpdate(c, changeOwner);
-    assertThrows(ValidationException.class, () -> failingUpdate.setTopic("\""));
+    assertThrows(ValidationException.class, () -> failingUpdate.setTopic("\"", topicValidator));
   }
 
   @Test
@@ -1672,7 +1682,7 @@
 
     // An update doesn't affect the Change-Id
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
 
@@ -1701,7 +1711,7 @@
 
     // An update doesn't affect the branch
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(newNotes(c).getChange().getDest()).isEqualTo(expectedBranch);
 
@@ -1722,7 +1732,7 @@
 
     // An update doesn't affect the owner
     ChangeUpdate update = newUpdate(c, otherUser);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
   }
@@ -1736,7 +1746,7 @@
 
     // An update doesn't affect the createdOn timestamp.
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(newNotes(c).getChange().getCreatedOn()).isEqualTo(createdOn);
   }
@@ -1751,7 +1761,7 @@
 
     // Various kinds of updates that update the timestamp.
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     Instant ts2 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts2).isGreaterThan(ts1);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index d13ccdd..064cd89 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.Objects.requireNonNull;
+import static org.mockito.Mockito.mock;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeNoteUtil.AttentionStatusInNoteDb;
 import com.google.gerrit.server.notedb.CommitRewriter.BackfillResult;
 import com.google.gerrit.server.notedb.CommitRewriter.CommitDiff;
@@ -74,10 +76,14 @@
   private @Inject CommitRewriter rewriter;
   @Inject private ChangeNoteUtil changeNoteUtil;
 
+  private TopicValidator topicValidator;
+
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
 
   @Before
-  public void setUp() throws Exception {}
+  public void setUp() throws Exception {
+    topicValidator = mock(TopicValidator.class);
+  }
 
   @After
   public void cleanUp() throws Exception {
@@ -1500,7 +1506,7 @@
     ChangeUpdate invalidMergedMessageUpdate = newUpdate(c, changeOwner);
     invalidMergedMessageUpdate.setChangeMessage(
         "Change has been successfully merged by " + changeOwner.getName());
-    invalidMergedMessageUpdate.setTopic("");
+    invalidMergedMessageUpdate.setTopic("", topicValidator);
 
     commitsToFix.add(invalidMergedMessageUpdate.commit());
     ChangeUpdate invalidCherryPickedMessageUpdate = newUpdate(c, changeOwner);
diff --git a/javatests/com/google/gerrit/server/patch/DiffValidatorsTest.java b/javatests/com/google/gerrit/server/patch/DiffValidatorsTest.java
deleted file mode 100644
index fa1d09e..0000000
--- a/javatests/com/google/gerrit/server/patch/DiffValidatorsTest.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.patch;
-
-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.entities.Patch.ChangeType;
-import com.google.gerrit.entities.Patch.FileMode;
-import com.google.gerrit.entities.Patch.PatchType;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Test class for {@link DiffValidators}. */
-public class DiffValidatorsTest {
-  @Inject private DiffValidators diffValidators;
-
-  @Before
-  public void setUpInjector() throws Exception {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-  }
-
-  @Test
-  public void fileSizeExceeded() {
-    int largeSize = 100000000;
-    FileDiffOutput fileDiff =
-        FileDiffOutput.builder()
-            .oldCommitId(ObjectId.zeroId())
-            .newCommitId(ObjectId.zeroId())
-            .comparisonType(ComparisonType.againstRoot())
-            .changeType(ChangeType.ADDED)
-            .patchType(Optional.of(PatchType.UNIFIED))
-            .oldPath(Optional.empty())
-            .newPath(Optional.of("f.txt"))
-            .oldMode(Optional.empty())
-            .newMode(Optional.of(FileMode.REGULAR_FILE))
-            .headerLines(ImmutableList.of())
-            .edits(ImmutableList.of())
-            .size(largeSize)
-            .sizeDelta(largeSize)
-            .build();
-    Exception thrown =
-        assertThrows(LargeObjectException.class, () -> diffValidators.validate(fileDiff));
-    assertThat(thrown)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "File size for file f.txt exceeded the max file size threshold."
-                    + " Threshold = %d bytes, Actual size = %d bytes",
-                DiffFileSizeValidator.DEFAULT_MAX_FILE_SIZE, largeSize));
-  }
-}
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index b119104..47d485d 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -23,7 +23,8 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -69,13 +70,18 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
-import java.util.Set;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 
+/**
+ * Tests queries against the project index.
+ *
+ * <p>Note, returned projects are sorted by name. Projects that start with a capital letter are
+ * returned first.
+ */
 @Ignore
 public abstract class AbstractQueryProjectsTest extends GerritServerTests {
   @Rule public final GerritTestName testName = new GerritTestName();
@@ -112,6 +118,8 @@
   protected Injector injector;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
+  protected ProjectInfo allProjectsInfo;
+  protected ProjectInfo allUsersInfo;
 
   protected abstract Injector createInjector();
 
@@ -141,6 +149,13 @@
     user = userFactory.create(userId);
     requestContext.setContext(newRequestContext(userId));
     currentUserInfo = gApi.accounts().id(userId.get()).get();
+
+    // All-Projects and All-Users are not indexed, index them now.
+    gApi.projects().name(allProjects.get()).index(/* indexChildren= */ false);
+    gApi.projects().name(allUsers.get()).index(/* indexChildren= */ false);
+
+    allProjectsInfo = gApi.projects().name(allProjects.get()).get();
+    allUsersInfo = gApi.projects().name(allUsers.get()).get();
   }
 
   protected void initAfterLifecycleStart() throws Exception {}
@@ -163,6 +178,13 @@
   }
 
   @Test
+  public void byEmptyQuery() throws Exception {
+    ProjectInfo project1 = createProject(name("project1"));
+    ProjectInfo project2 = createProject(name("project2"));
+    assertQuery("", allProjectsInfo, allUsersInfo, project1, project2);
+  }
+
+  @Test
   public void byName() throws Exception {
     assertQuery("name:project");
     assertQuery("name:non-existing");
@@ -170,6 +192,8 @@
     ProjectInfo project = createProject(name("project"));
 
     assertQuery("name:" + project.name, project);
+    assertQuery("name:" + allProjects.get(), allProjectsInfo);
+    assertQuery("name:" + allUsers.get(), allUsersInfo);
 
     // only exact match
     ProjectInfo projectWithHyphen = createProject(name("project-with-hyphen"));
@@ -178,6 +202,49 @@
   }
 
   @Test
+  public void byPrefix() throws Exception {
+    assume().that(getSchemaVersion() >= 8).isTrue();
+
+    assertQuery("prefix:project");
+    assertQuery("prefix:non-existing");
+    assertQuery("prefix:All", allProjectsInfo, allUsersInfo);
+    assertQuery("prefix:All-", allProjectsInfo, allUsersInfo);
+
+    ProjectInfo project1 = createProject(name("project-1"));
+    ProjectInfo project2 = createProject(name("project-2"));
+    ProjectInfo testProject = createProject(name("test-project"));
+
+    assertQuery("prefix:project", project1, project2);
+    assertQuery("prefix:test", testProject);
+    assertQuery("prefix:TEST");
+  }
+
+  @Test
+  public void byPrefixWithOtherCase() throws Exception {
+    assume().that(getSchemaVersion() >= 8).isTrue();
+
+    assertQuery("prefix:all");
+
+    createProject(name("test-project"));
+    assertQuery("prefix:TEST");
+  }
+
+  @Test
+  public void bySubstring() throws Exception {
+    assertQuery("substring:non-existing");
+
+    ProjectInfo project1 = createProject(name("project-1"));
+    ProjectInfo project2 = createProject(name("project-2"));
+    ProjectInfo testProject = createProject(name("test-project"));
+    ProjectInfo myTests = createProject(name("MY-TESTS"));
+
+    assertQuery("substring:project", allProjectsInfo, project1, project2, testProject);
+    assertQuery("substring:PROJECT", allProjectsInfo, project1, project2, testProject);
+    assertQuery("substring:test", myTests, testProject);
+    assertQuery("substring:TEST", myTests, testProject);
+  }
+
+  @Test
   public void byParent() throws Exception {
     assertQuery("parent:project");
     ProjectInfo parent = createProject(name("parent"));
@@ -188,12 +255,32 @@
 
   @Test
   public void byParentOfAllProjects() throws Exception {
-    Set<String> excludedProjects = ImmutableSet.of(allProjects.get(), allUsers.get());
-    ProjectInfo[] projects =
-        gApi.projects().list().get().stream()
-            .filter(p -> !excludedProjects.contains(p.name))
-            .toArray(s -> new ProjectInfo[s]);
-    assertQuery("parent:" + allProjects.get(), projects);
+    assume().that(getSchemaVersion() < 7).isTrue();
+
+    ProjectInfo parent1 = createProject(name("parent1"));
+    createProject(name("child"), parent1.name);
+
+    ProjectInfo parent2 = createProject(name("parent2"));
+    createProject(name("child2"), parent2.name);
+
+    // All-Users should be returned as well, since it's a direct child project under
+    // All-Projects, but it's missing in the result since the parent1 field in the index is not set
+    // for projects that don't have 'access.inheritsFrom' set in project.config (which is the case
+    // for the All-Users project).
+    assertQuery("parent:" + allProjects.get(), parent1, parent2);
+  }
+
+  @Test
+  public void byParentOfAllProjects2() throws Exception {
+    assume().that(getSchemaVersion() >= 7).isTrue();
+
+    ProjectInfo parent1 = createProject(name("parent1"));
+    createProject(name("child"), parent1.name);
+
+    ProjectInfo parent2 = createProject(name("parent2"));
+    createProject(name("child2"), parent2.name);
+
+    assertQuery("parent:" + allProjects.get(), allUsersInfo, parent1, parent2);
   }
 
   @Test
@@ -231,7 +318,7 @@
 
     ProjectInfo project1 = createProjectWithState(name("project1"), ProjectState.ACTIVE);
     ProjectInfo project2 = createProjectWithState(name("project2"), ProjectState.READ_ONLY);
-    assertQuery("state:active", project1);
+    assertQuery("state:active", allProjectsInfo, allUsersInfo, project1);
     assertQuery("state:read-only", project2);
   }
 
@@ -274,8 +361,10 @@
     String query =
         "name:" + project1.name + " OR name:" + project2.name + " OR name:" + project3.name;
     List<ProjectInfo> result = assertQuery(query, project1, project2, project3);
+    assertThat(Iterables.getLast(result)._moreProjects).isNull();
 
-    assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+    assertThat(Iterables.getLast(result)._moreProjects).isTrue();
   }
 
   @Test
@@ -343,6 +432,7 @@
     return gApi.projects().create(in).get();
   }
 
+  @CanIgnoreReturnValue
   protected ProjectInfo createProject(String name, String parent) throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name;
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 53f9d9d..2ae73b3 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -21,6 +21,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/guice",
         "//lib/truth",
     ],
diff --git a/lib/BUILD b/lib/BUILD
index 96a5325..5bb3593 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -510,6 +510,16 @@
     exports = ["@icu4j//jar"],
 )
 
+java_library(
+    name = "roaringbitmap",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@roaringbitmap-shims//jar",
+        "@roaringbitmap//jar",
+    ],
+)
+
 sh_test(
     name = "nongoogle_test",
     srcs = ["nongoogle_test.sh"],
diff --git a/modules/jgit b/modules/jgit
index 74fa245..24b6a35 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 74fa245b3c3ccf13afcbec7911c7c8459e48527d
+Subproject commit 24b6a35d30e76317c043f27c6159e57c39b303d8
diff --git a/plugins/download-commands b/plugins/download-commands
index b90e523..42b608a 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit b90e523f589a0e2902823233010163f453243926
+Subproject commit 42b608a64bdb1350656b2ca09643ed4173cd6e73
diff --git a/plugins/gitiles b/plugins/gitiles
index 20f65c2..4e8bd70 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 20f65c2067b9190d1c85fbf61e5d72edf4493724
+Subproject commit 4e8bd706e87eb11e3cfe2bfa9bbcb29020f39482
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index afbc783..df9af40 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -3,7 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CoverageRange, TokenHighlightEventDetails} from './diff';
+import {CoverageRange, FileRange, TokenHighlightEventDetails} from './diff';
 import {BasePatchSetNum, ChangeInfo, RevisionPatchSetNum} from './rest-api';
 
 /**
@@ -23,6 +23,8 @@
   change: ChangeInfo;
   basePatchNum: BasePatchSetNum;
   patchNum: RevisionPatchSetNum;
+  fileRange: FileRange;
+  /** @deprecated rely on fileRange.path */
   path: string;
 }
 
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 9963fdc..24110ed 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -10,7 +10,7 @@
  */
 
 import {CursorMoveResult} from './core';
-import {CommentRange} from './rest-api';
+import {BasePatchSetNum, CommentRange, RevisionPatchSetNum} from './rest-api';
 
 /**
  * Diff type in preferences
@@ -306,6 +306,16 @@
   code_range: LineRange;
 }
 
+export interface FileRange {
+  basePath?: string;
+  path: string;
+}
+
+export interface PatchRange {
+  patchNum: RevisionPatchSetNum;
+  basePatchNum: BasePatchSetNum;
+}
+
 /**
  * LOST LineNumber is for ported comments without a range, they have their own
  * line number and are added on top of the FILE row in <gr-diff>.
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index d3d012d..4aa3aaa 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -32,6 +32,7 @@
   REVERT_SUBMISSION = 'revert_submission',
   POST_REVERT = 'postrevert',
   ADMIN_MENU_LINKS = 'admin-menu-links',
+  SHOW_DIFF = 'showdiff',
 }
 
 export declare interface PluginApi {
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index db69fcc..9869802 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -516,6 +516,31 @@
 }
 
 /**
+ * The CommentInfo entity contains information about an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export interface CommentInfo {
+  id: UrlEncodedCommentId;
+  updated: Timestamp;
+  // TODO(TS): Make this required. Every comment must have patch_set set.
+  patch_set?: RevisionPatchSetNum;
+  path?: string;
+  side?: CommentSide;
+  parent?: number;
+  line?: number;
+  range?: CommentRange;
+  in_reply_to?: UrlEncodedCommentId;
+  message?: string;
+  author?: AccountInfo;
+  tag?: string;
+  unresolved?: boolean;
+  change_message_id?: string;
+  commit_id?: string;
+  context_lines?: ContextLine[];
+  source_content_type?: string;
+}
+
+/**
  * The CommentRange entity describes the range of an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
  *
@@ -542,6 +567,15 @@
   end_character: number;
 }
 
+/**
+ * The side on which the comment was added
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export enum CommentSide {
+  REVISION = 'REVISION',
+  PARENT = 'PARENT',
+}
+
 export declare interface ConfigListParameterInfo
   extends ConfigParameterInfoBase {
   type: ConfigParameterInfoType.LIST;
@@ -571,6 +605,15 @@
   inherited_value?: string;
 }
 
+/**
+ * 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 {
+  line_number: number;
+  context_line: string;
+}
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
 export declare interface ContributorAgreementInfo {
   name: string;
@@ -1229,3 +1272,6 @@
 ): res is Base64FileContent {
   return (res as Base64FileContent).ok;
 }
+
+// The URL encoded UUID of the comment
+export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
new file mode 100644
index 0000000..f75f6e0
--- /dev/null
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {CommentRange, NumericChangeId, RevisionPatchSetNum} from './rest-api';
+
+export declare interface SuggestionsPluginApi {
+  /**
+   * Must only be called once. You cannot register twice. You cannot unregister.
+   */
+  register(provider: SuggestionsProvider): void;
+}
+
+export declare interface SuggestCodeRequest {
+  prompt: string;
+  changeNumber: NumericChangeId;
+  patchsetNumber: RevisionPatchSetNum;
+  filePath: string;
+  range?: CommentRange;
+  lineNumber?: Number;
+}
+
+export declare interface SuggestionsProvider {
+  /**
+   * Gerrit calls this method when ...
+   * - ... user types a comment draft
+   */
+  suggestCode(commentData: SuggestCodeRequest): Promise<SuggestCodeResponse>;
+}
+
+declare interface SuggestCodeResponse {
+  responseCode: ResponseCode;
+  suggestions: Suggestion[];
+}
+
+export declare interface Suggestion {
+  replacement: string;
+  newRange?: CommentRange;
+}
+
+export enum ResponseCode {
+  OK = 'OK',
+  ERROR = 'ERROR',
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index b9ed56b..25d25b0 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -13,6 +13,7 @@
 import {
   AuthType,
   ChangeStatus,
+  CommentSide,
   ConfigParameterInfoType,
   DefaultDisplayNameConfig,
   EditableAccountField,
@@ -32,6 +33,7 @@
 export {
   AuthType,
   ChangeStatus,
+  CommentSide,
   ConfigParameterInfoType,
   DefaultDisplayNameConfig,
   EditableAccountField,
@@ -160,15 +162,6 @@
 }
 
 /**
- * The side on which the comment was added
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
- */
-export enum CommentSide {
-  REVISION = 'REVISION',
-  PARENT = 'PARENT',
-}
-
-/**
  * Allowed app themes
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
  */
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index cb17de7..852f907 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -13,7 +13,11 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
 
 // Exported for tests
 export interface PluginInfoWithName extends PluginInfo {
@@ -22,8 +26,6 @@
 
 @customElement('gr-plugin-list')
 export class GrPluginList extends LitElement {
-  readonly path = '/admin/plugins';
-
   /**
    * URL params passed from the router.
    */
@@ -70,7 +72,7 @@
         .items=${this.plugins}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.path}
+        .path=${createAdminUrl({adminView: AdminChildView.PLUGINS})}
       >
         <table id="list" class="genericList">
           <tbody>
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 8a5bf47..c65371b 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
@@ -37,6 +37,7 @@
 import {ProgressStatus} from '../../../constants/constants';
 import {StandardLabels} from '../../../utils/label-util';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ReviewResult} from '../../../types/common';
 
 const change1: ChangeInfo = {
   ...createChange(),
@@ -208,7 +209,7 @@
     );
     stubRestApi('saveChangeReview').callsFake(
       (_changeNum, _patchNum, _review, errFn) =>
-        Promise.resolve(new Response()).then(res => {
+        Promise.resolve(undefined).then(res => {
           errFn && errFn();
           return res;
         })
@@ -359,7 +360,7 @@
     );
     await selectChange(change);
     await element.updateComplete;
-    const saveChangeReview = mockPromise<Response>();
+    const saveChangeReview = mockPromise<ReviewResult>();
     stubRestApi('saveChangeReview').returns(saveChangeReview);
 
     queryAndAssert<GrButton>(element, '#voteFlowButton').click();
@@ -411,7 +412,7 @@
       ProgressStatus.RUNNING
     );
 
-    saveChangeReview.resolve({...new Response(), status: 200});
+    saveChangeReview.resolve({});
     await waitUntil(
       () =>
         element.progressByChange.get(1 as NumericChangeId) ===
@@ -445,7 +446,7 @@
 
       stubRestApi('saveChangeReview').callsFake(
         (_changeNum, _patchNum, _review, errFn) =>
-          Promise.resolve(new Response()).then(res => {
+          Promise.resolve({}).then(res => {
             errFn && errFn();
             return res;
           })
@@ -503,7 +504,7 @@
 
       stubRestApi('saveChangeReview').callsFake(
         (_changeNum, _patchNum, _review, errFn) =>
-          Promise.resolve(new Response()).then(res => {
+          Promise.resolve(undefined).then(res => {
             errFn && errFn();
             return res;
           })
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index d9be9ca..e4c413f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
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 fae8203..57bb875 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
@@ -25,7 +25,6 @@
 import {
   changeIsOpen,
   isOwner,
-  ListChangesOption,
   listChangesOptionsToHex,
 } from '../../../utils/change-util';
 import {
@@ -48,6 +47,7 @@
   isDetailedLabelInfo,
   isQuickLabelInfo,
   LabelInfo,
+  ListChangesOption,
   NumericChangeId,
   PatchSetNumber,
   RequestPayload,
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 623f2d7..fd4bf73 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
@@ -711,7 +711,7 @@
         )
         .returns(review);
       const saveStub = stubRestApi('saveChangeReview').returns(
-        Promise.resolve(new Response())
+        Promise.resolve({})
       );
       const setReviewOnRevert = element.setReviewOnRevert(changeId) as Promise<
         undefined | Response
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 eaa9215..2276aa4 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
@@ -1436,7 +1436,6 @@
           id="fileListHeader"
           .account=${this.account}
           .change=${this.change}
-          .changeNum=${this.changeNum}
           .commitInfo=${this.revision?.commit}
           .changeUrl=${this.computeChangeUrl()}
           .editMode=${this.editMode}
@@ -1875,7 +1874,7 @@
   handleReplySent() {
     assertIsDefined(this.replyModal);
     this.replyModal.close();
-    this.getChangeModel().navigateToChangeResetReload();
+    this.getCommentsModel().reloadAllComments();
   }
 
   private handleReplyCancel() {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 7bbfd93..d548454 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -320,7 +320,6 @@
               !this.allowConflicts ? ' the uploader:' : ''
             } <gr-account-chip
                 .account=${this.allowConflicts ? this.account : this.uploader}
-                .hideHovercard=${true}
               ></gr-account-chip
               ><span></div>`
           )}
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 abb637c..d053928 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
@@ -15,6 +15,7 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-file-status/gr-file-status';
+import '../gr-comments-summary/gr-comments-summary';
 import {assertIsDefined} from '../../../utils/common-util';
 import {asyncForeach} from '../../../utils/async-util';
 import {FilesExpandedState} from '../gr-file-list-constants';
@@ -247,18 +248,12 @@
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
-  @state() numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+  @state() numFilesShown = 0;
 
   @state()
   fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
 
   // Private but used in tests.
-  shownFiles: NormalizedFileInfo[] = [];
-
-  @state()
-  private reportinShownFilesIncrement = 0;
-
-  // Private but used in tests.
   @state()
   expandedFiles: PatchSetFile[] = [];
 
@@ -839,12 +834,7 @@
     }
     if (changedProperties.has('files')) {
       this.filesChanged();
-    }
-    if (
-      changedProperties.has('files') ||
-      changedProperties.has('numFilesShown')
-    ) {
-      this.shownFiles = this.computeFilesShown();
+      this.numFilesShown = Math.min(this.files.length, DEFAULT_NUM_FILES_SHOWN);
     }
     if (changedProperties.has('expandedFiles')) {
       this.expandedFilesChanged(changedProperties.get('expandedFiles'));
@@ -1012,7 +1002,10 @@
           class="extra-col"
           .name=${headerEndpoint}
           role="columnheader"
-        ></gr-endpoint-decorator>
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
       `
     );
   }
@@ -1024,7 +1017,7 @@
     const sizeBarLayout = this.computeSizeBarLayout();
 
     return incrementalRepeat({
-      values: this.shownFiles,
+      values: this.files,
       mapFn: (f, i) =>
         this.renderFileRow(
           f as NormalizedFileInfo,
@@ -1035,6 +1028,8 @@
         ),
       initialCount: this.fileListIncrement,
       targetFrameRate: 1,
+      startAt: 0,
+      endAt: this.numFilesShown,
     });
   }
 
@@ -1045,8 +1040,7 @@
     showDynamicColumns: boolean,
     showPrependedDynamicColumns: boolean
   ) {
-    this.reportRenderedRow(index);
-    const previousFileName = this.shownFiles[index - 1]?.__path;
+    const previousFileName = this.files[index - 1]?.__path;
     const patchSetFile = this.computePatchSetFile(file);
     return html` <div class="stickyArea">
       <div
@@ -1800,10 +1794,10 @@
     // expanded list.
     const newFiles: PatchSetFile[] = [];
     let path: string;
-    for (let i = 0; i < this.shownFiles.length; i++) {
-      path = this.shownFiles[i].__path;
+    for (let i = 0; i < this.numFilesShown; i++) {
+      path = this.files[i].__path;
       if (!this.expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this.computePatchSetFile(this.shownFiles[i]));
+        newFiles.push(this.computePatchSetFile(this.files[i]));
       }
     }
 
@@ -2226,26 +2220,6 @@
     );
   }
 
-  private computeFilesShown(): NormalizedFileInfo[] {
-    const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
-
-    const filesShown = this.files.slice(0, this.numFilesShown);
-    fire(this, 'files-shown-changed', {length: filesShown.length});
-
-    // Start the timer for the rendering work here because this is where the
-    // shownFiles property is being set, and shownFiles is used in the
-    // dom-repeat binding.
-    this.reporting.time(Timing.FILE_RENDER);
-
-    // How many more files are being shown (if it's an increase).
-    this.reportinShownFilesIncrement = Math.max(
-      0,
-      filesShown.length - previousNumFilesShown
-    );
-
-    return filesShown;
-  }
-
   // Private but used in tests.
   updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
@@ -2265,6 +2239,9 @@
 
   private incrementNumFilesShown() {
     this.numFilesShown += this.fileListIncrement;
+    if (this.numFilesShown > this.files.length) {
+      this.numFilesShown = this.files.length;
+    }
   }
 
   private computeFileListControlClass() {
@@ -2472,7 +2449,8 @@
    */
   computeSizeBarLayout() {
     const stats: SizeBarLayout = createDefaultSizeBarLayout();
-    this.shownFiles
+    this.files
+      .slice(0, this.numFilesShown)
       .filter(f => !isMagicPath(f.__path))
       .forEach(f => {
         if (f.lines_inserted) {
@@ -2598,24 +2576,6 @@
     return this.filesExpanded === FilesExpandedState.NONE;
   }
 
-  /**
-   * Method to call via binding when each file list row is rendered. This
-   * allows approximate detection of when the dom-repeat has completed
-   * rendering.
-   *
-   * @param index The index of the row being rendered.
-   * Private but used in tests.
-   */
-  reportRenderedRow(index: number) {
-    if (index === this.shownFiles.length - 1) {
-      setTimeout(() => {
-        this.reporting.timeEnd(Timing.FILE_RENDER, {
-          count: this.reportinShownFilesIncrement,
-        });
-      }, 1);
-    }
-  }
-
   private handleReloadingDiffPreference() {
     this.getUserModel().getDiffPreferences();
   }
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 daf0891..ab9d038 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
@@ -398,6 +398,7 @@
       element.files = createFiles(250);
       await element.updateComplete;
       await waitEventLoop();
+      assert.equal(200, element.numFilesShown);
 
       assert.equal(
         queryAll<HTMLDivElement>(element, '.file-row').length,
@@ -422,19 +423,9 @@
       await waitEventLoop();
 
       assert.equal(element.numFilesShown, 250);
-      assert.equal(element.shownFiles.length, 250);
       assert.isTrue(controlRow.classList.contains('invisible'));
     });
 
-    test('rendering each row calls the reportRenderedRow method', async () => {
-      const renderedStub = sinon.stub(element, 'reportRenderedRow');
-      element.files = createFiles(10);
-      await element.updateComplete;
-
-      assert.equal(queryAll<HTMLDivElement>(element, '.file-row').length, 10);
-      assert.equal(renderedStub.callCount, 10);
-    });
-
     test('calculate totals for patch number', async () => {
       element.files = [
         {
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 13662982..bfece63 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -5,6 +5,7 @@
  */
 import '@polymer/iron-selector/iron-selector';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
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 4abfeff..65d36ec 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -740,9 +740,8 @@
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
         this.isDeletingChangeMsg = false;
-        // TODO: Fix the type casting. Might actually be a bug.
         fire(this, 'change-message-deleted', {
-          message: this.message as ChangeMessage,
+          message: this.message!,
         });
       });
   }
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 61136d7..3762cef 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
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html, nothing, TemplateResult} from 'lit';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icon/gr-icon';
 import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
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 9421842..fa44e18 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
@@ -58,6 +58,7 @@
   Suggestion,
   UserId,
   isDraft,
+  ChangeViewChangeInfo,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
@@ -130,6 +131,7 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {ironAnnouncerRequestAvailability} from '../../polymer-util';
+import {GrReviewerUpdatesParser} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -596,6 +598,13 @@
     );
     subscribe(
       this,
+      () => this.getUserModel().account$,
+      account => {
+        this.account = account;
+      }
+    );
+    subscribe(
+      this,
       () => this.getConfigModel().serverConfig$,
       config => {
         this.serverConfig = config;
@@ -658,10 +667,6 @@
       this
     );
 
-    this.restApiService.getAccount().then(account => {
-      if (account) this.account = account;
-    });
-
     this.addEventListener(
       'comment-editing-changed',
       (e: CustomEvent<CommentEditingChangedDetail>) => {
@@ -1408,37 +1413,39 @@
       reviewInput.remove_from_attention_set
     );
 
-    await this.patchsetLevelGrComment?.save();
+    if (this.patchsetLevelGrComment) {
+      this.patchsetLevelGrComment.disableAutoSaving = true;
+      await this.restApiService.awaitPendingDiffDrafts();
+      const comment = this.patchsetLevelGrComment.convertToCommentInput();
+      if (comment && comment.path && comment.message) {
+        reviewInput.comments ??= {};
+        reviewInput.comments[comment.path] ??= [];
+        reviewInput.comments[comment.path].push(comment);
+      }
+    }
 
     assertIsDefined(this.change, 'change');
     reviewInput.reviewers = this.computeReviewers();
 
     const errFn = (r?: Response | null) => this.handle400Error(r);
     return this.saveReview(reviewInput, errFn)
-      .then(response => {
-        if (!response) {
-          // Null or undefined response indicates that an error handler
-          // took responsibility, so just return.
-          return;
-        }
-        if (!response.ok) {
-          fireServerError(response);
-          return;
-        }
+      .then(result => {
+        this.getChangeModel().updateStateChange(
+          GrReviewerUpdatesParser.parse(
+            result?.change_info as ChangeViewChangeInfo
+          )
+        );
 
         this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
         fireNoBubble(this, 'send', {});
         fireIronAnnounce(this, 'Reply sent');
-        return;
       })
-      .then(result => {
+      .finally(() => {
         this.disabled = false;
-        return result;
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
+        if (this.patchsetLevelGrComment) {
+          this.patchsetLevelGrComment.disableAutoSaving = false;
+        }
       });
   }
 
@@ -1836,7 +1843,8 @@
       this.change._number,
       this.latestPatchNum,
       review,
-      errFn
+      errFn,
+      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 27b4cc8..3c3c246 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
@@ -21,7 +21,6 @@
   DraftsAction,
   ReviewerState,
 } from '../../../constants/constants';
-import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {StandardLabels} from '../../../utils/label-util';
 import {
   createAccountWithEmail,
@@ -170,14 +169,7 @@
         new Promise((resolve, reject) => {
           try {
             const result = jsonResponseProducer(review) || {};
-            const resultStr = JSON_PREFIX + JSON.stringify(result);
-            resolve({
-              ...new Response(),
-              ok: true,
-              text() {
-                return Promise.resolve(resultStr);
-              },
-            });
+            resolve(result);
           } catch (err) {
             reject(err);
           }
@@ -1522,62 +1514,6 @@
     assert.isTrue(element.reviewersMutated);
   });
 
-  test('400 converts to human-readable server-error', async () => {
-    stubRestApi('saveChangeReview').callsFake(
-      (_changeNum, _patchNum, _review, errFn) => {
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
-        errFn!(
-          cloneableResponse(
-            400,
-            '....{"reviewers":{"id1":{"error":"human readable"}}}'
-          ) as Response
-        );
-        return Promise.resolve(new Response());
-      }
-    );
-
-    const promise = mockPromise();
-    const listener = (event: Event) => {
-      if (event.target !== document) return;
-      (event as CustomEvent).detail.response.text().then((body: string) => {
-        if (body === 'human readable') {
-          promise.resolve();
-        }
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-
-    await element.updateComplete;
-    element.send(false, false);
-    await promise;
-  });
-
-  test('non-json 400 is treated as a normal server-error', async () => {
-    stubRestApi('saveChangeReview').callsFake(
-      (_changeNum, _patchNum, _review, errFn) => {
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
-        errFn!(cloneableResponse(400, 'Comment validation error!') as Response);
-        return Promise.resolve(new Response());
-      }
-    );
-    const promise = mockPromise();
-    const listener = (event: Event) => {
-      if (event.target !== document) return;
-      (event as CustomEvent).detail.response.text().then((body: string) => {
-        if (body === 'Comment validation error!') {
-          promise.resolve();
-        }
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    await element.updateComplete;
-    element.send(false, false);
-    await promise;
-  });
-
   test('filterReviewerSuggestion', () => {
     const owner = makeAccount();
     const reviewer1 = makeAccount();
@@ -2490,6 +2426,7 @@
       await waitUntil(
         () => element.patchsetLevelDraftMessage === 'hello world'
       );
+      await element.updateComplete;
 
       const saveReviewPromise = interceptSaveReview();
 
@@ -2499,7 +2436,7 @@
 
       const review = await saveReviewPromise;
 
-      assert.deepEqual(autoSaveStub.callCount, 1);
+      assert.deepEqual(autoSaveStub.callCount, 0);
 
       assert.deepEqual(review, {
         drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
@@ -2514,6 +2451,65 @@
             user: 999 as UserId,
           },
         ],
+        comments: {
+          '/PATCHSET_LEVEL': [
+            {
+              message: 'hello world',
+              path: '/PATCHSET_LEVEL',
+              unresolved: false,
+            },
+          ],
+        },
+        remove_from_attention_set: [],
+        ignore_automatic_attention_set_rules: true,
+      });
+    });
+
+    test('sending waits for inflight autosave', async () => {
+      const patchsetLevelComment = queryAndAssert<GrComment>(
+        element,
+        '#patchsetLevelComment'
+      );
+
+      const waitForPendingDiffDrafts = stubRestApi(
+        'awaitPendingDiffDrafts'
+      ).returns(Promise.resolve());
+
+      patchsetLevelComment.messageText = 'hello world';
+      await waitUntil(
+        () => element.patchsetLevelDraftMessage === 'hello world'
+      );
+      await element.updateComplete;
+
+      const saveReviewPromise = interceptSaveReview();
+
+      queryAndAssert<GrButton>(element, '.send').click();
+
+      const review = await saveReviewPromise;
+      assert.deepEqual(waitForPendingDiffDrafts.callCount, 1);
+
+      assert.deepEqual(review, {
+        drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+        labels: {
+          'Code-Review': 0,
+          Verified: 0,
+        },
+        reviewers: [],
+        add_to_attention_set: [
+          {
+            reason: '<GERRIT_ACCOUNT_1> replied on the change',
+            user: 999 as UserId,
+          },
+        ],
+        comments: {
+          '/PATCHSET_LEVEL': [
+            {
+              message: 'hello world',
+              path: '/PATCHSET_LEVEL',
+              unresolved: false,
+            },
+          ],
+        },
         remove_from_attention_set: [],
         ignore_automatic_attention_set_rules: true,
       });
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
index db49e8d..d35855d 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-formatted-text/gr-formatted-text';
 import {customElement, property} from 'lit/decorators.js';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
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 1999e1f..a0a3a44 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -55,6 +55,9 @@
       fontStyles,
       css`
         .container {
+          /* Allows hiding the check results along with the comments
+             when the user presses the keyboard shortcut 'h'. */
+          display: var(--gr-comment-thread-display, block);
           font-family: var(--font-family);
           margin: 0 var(--spacing-s) var(--spacing-s);
           background-color: var(--unresolved-comment-background-color);
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index 5510aa3..ca0480c9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -33,6 +33,9 @@
   @property({type: String})
   loginUrl = '/login';
 
+  @property({type: String})
+  loginText = 'Sign in';
+
   @property({type: Boolean})
   showSignInButton = false;
 
@@ -82,7 +85,7 @@
 
     return html`
       <gr-button id="signIn" class="signInLink" link="" slot="footer">
-        <a class="signInLink" href=${this.loginUrl}>Sign in</a>
+        <a class="signInLink" href=${this.loginUrl}>${this.loginText}</a>
       </gr-button>
     `;
   }
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 570d393..c8a1c39 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -113,6 +113,9 @@
   @property({type: String})
   loginUrl = '/login';
 
+  @property({type: String})
+  loginText = 'Sign in';
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly getAuthService = resolve(this, authServiceToken);
@@ -164,6 +167,7 @@
           id="errorDialog"
           @dismiss=${() => this.errorModal.close()}
           .loginUrl=${this.loginUrl}
+          .loginText=${this.loginText}
         ></gr-error-dialog>
       </dialog>
       <dialog
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 8df56b0..c70e677 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
@@ -116,6 +116,9 @@
   @property({type: String})
   loginUrl = '/login';
 
+  @property({type: String})
+  loginText = 'Sign in';
+
   @property({type: Boolean})
   mobileSearchHidden = false;
 
@@ -446,7 +449,7 @@
           ></gr-icon>
         </div>
         ${this.renderRegister()}
-        <a class="loginButton" href=${this.loginUrl}>Sign in</a>
+        <a class="loginButton" href=${this.loginUrl}>${this.loginText}</a>
         <a
           class="settingsButton"
           href="${getBaseUrl()}/settings/"
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 d06b405..cbbcee0 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
@@ -33,10 +33,7 @@
       allow_browser_notifications: true,
     };
     userModel.setPreferences(prefs);
-    await waitUntilObserved(
-      userModel.preferences$,
-      pref => pref.allow_browser_notifications === true
-    );
+
     await waitUntilObserved(
       userModel.preferences$,
       pref => pref.allow_browser_notifications === true
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 864f559..bb67a40 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -102,6 +102,7 @@
 import {
   InteractivePromise,
   interactivePromise,
+  noAwait,
   timeoutPromise,
 } from '../../../utils/async-util';
 
@@ -506,9 +507,8 @@
 
   /**  gr-page middleware that warms the REST API's logged-in cache line. */
   private loadUserMiddleware(_: PageContext, next: PageNextCallback) {
-    this.restApiService.getLoggedIn().then(() => {
-      next();
-    });
+    noAwait(this.restApiService.getLoggedIn());
+    next();
   }
 
   /**
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 02d5124..769b064 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
@@ -42,7 +42,7 @@
 import {Timing} from '../../../constants/reporting';
 import {changeModelToken} from '../../../models/change/change-model';
 
-interface FilePreview {
+export interface FilePreview {
   filepath: string;
   preview: DiffInfo;
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 00e013b..04a55fd 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -204,11 +204,11 @@
 
   /**
    * Get the comments (with drafts and robot comments) for a path and
-   * patch-range. Returns an object with left and right properties mapping to
-   * arrays of comments in on either side of the patch range for that path.
+   * patch-range. Returns an array containing comments from either side of the
+   * patch range for that path.
    *
-   * @param patchRange The patch-range object containing patchNum
-   * and basePatchNum properties to represent the range.
+   * @param patchRange The patch-range object containing patchNum and
+   * basePatchNum properties to represent the range.
    */
   getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
     let comments: Comment[] = [];
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 d4cdb49..7efc72b 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
@@ -385,6 +385,11 @@
   override connectedCallback() {
     super.connectedCallback();
     this.subscribeToChecks();
+    this.getPluginLoader().jsApiService.handleShowDiff({
+      change: this.change!,
+      fileRange: this.file!,
+      patchRange: this.patchRange!,
+    });
   }
 
   override disconnectedCallback() {
@@ -680,6 +685,7 @@
                 change: this.change!,
                 basePatchNum: this.patchRange!.basePatchNum,
                 patchNum: this.patchRange!.patchNum,
+                fileRange: this.file!,
                 path: this.path!,
               },
               highlight
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 df9a634..8f98497 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
@@ -69,11 +69,13 @@
 
   setup(async () => {
     stubRestApi('getAccount').callsFake(() => Promise.resolve(account));
-    element = await fixture(html`<gr-diff-host></gr-diff-host>`);
-    element.changeNum = 123 as NumericChangeId;
-    element.path = 'some/path';
-    element.change = createChange();
-    element.patchRange = createPatchRange();
+    element = await fixture(html`<gr-diff-host
+      .changeNum=${123 as NumericChangeId}
+      .path=${'some/path'}
+      .file=${{path: 'some/path'}}
+      .change=${createChange()}
+      .patchRange=${createPatchRange()}
+    ></gr-diff-host>`);
     getDiffRestApiStub = stubRestApi('getDiff');
     // Fall back in case a test forgets to set one up
     getDiffRestApiStub.returns(Promise.resolve(createDiff()));
@@ -606,82 +608,73 @@
     assert.equal(stub.lastCall.args.length, 0);
   });
 
-  suite('blame', () => {
-    setup(async () => {
-      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
-      element.changeNum = 123 as NumericChangeId;
-      element.path = 'some/path';
-      await element.updateComplete;
-    });
+  test('clearBlame', async () => {
+    element.blame = [];
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    const isBlameLoadedStub = sinon.stub();
+    element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+    element.clearBlame();
+    await element.updateComplete;
+    assert.isNull(element.blame);
+    assert.isTrue(isBlameLoadedStub.calledOnce);
+    assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
+  });
 
-    test('clearBlame', async () => {
-      element.blame = [];
-      await element.updateComplete;
-      assertIsDefined(element.diffElement);
-      const isBlameLoadedStub = sinon.stub();
-      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
-      element.clearBlame();
-      await element.updateComplete;
-      assert.isNull(element.blame);
-      assert.isTrue(isBlameLoadedStub.calledOnce);
-      assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
-    });
+  test('loadBlame', async () => {
+    const mockBlame: BlameInfo[] = [createBlame()];
+    const showAlertStub = sinon.stub();
+    element.addEventListener('show-alert', showAlertStub);
+    const getBlameStub = stubRestApi('getBlame').returns(
+      Promise.resolve(mockBlame)
+    );
+    const changeNum = 42 as NumericChangeId;
+    element.changeNum = changeNum;
+    element.patchRange = createPatchRange();
+    element.path = 'foo/bar.baz';
+    await element.updateComplete;
+    const isBlameLoadedStub = sinon.stub();
+    element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
 
-    test('loadBlame', async () => {
-      const mockBlame: BlameInfo[] = [createBlame()];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      const getBlameStub = stubRestApi('getBlame').returns(
-        Promise.resolve(mockBlame)
+    return element.loadBlame().then(() => {
+      assert.isTrue(
+        getBlameStub.calledWithExactly(
+          changeNum,
+          1 as RevisionPatchSetNum,
+          'foo/bar.baz',
+          true
+        )
       );
-      const changeNum = 42 as NumericChangeId;
-      element.changeNum = changeNum;
-      element.patchRange = createPatchRange();
-      element.path = 'foo/bar.baz';
-      await element.updateComplete;
-      const isBlameLoadedStub = sinon.stub();
-      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+      assert.isFalse(showAlertStub.called);
+      assert.equal(element.blame, mockBlame);
+      assert.isTrue(isBlameLoadedStub.calledOnce);
+      assert.isTrue(isBlameLoadedStub.args[0][0].detail.value);
+    });
+  });
 
-      return element.loadBlame().then(() => {
-        assert.isTrue(
-          getBlameStub.calledWithExactly(
-            changeNum,
-            1 as RevisionPatchSetNum,
-            'foo/bar.baz',
-            true
-          )
-        );
-        assert.isFalse(showAlertStub.called);
-        assert.equal(element.blame, mockBlame);
-        assert.isTrue(isBlameLoadedStub.calledOnce);
-        assert.isTrue(isBlameLoadedStub.args[0][0].detail.value);
+  test('loadBlame empty', async () => {
+    const mockBlame: BlameInfo[] = [];
+    const showAlertStub = sinon.stub();
+    const isBlameLoadedStub = sinon.stub();
+    element.addEventListener('show-alert', showAlertStub);
+    element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+    stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
+    const changeNum = 42 as NumericChangeId;
+    element.changeNum = changeNum;
+    element.patchRange = createPatchRange();
+    element.path = 'foo/bar.baz';
+    await element.updateComplete;
+    return element
+      .loadBlame()
+      .then(() => {
+        assert.isTrue(false, 'Promise should not resolve');
+      })
+      .catch(() => {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isNull(element.blame);
+        // We don't expect a call because
+        assert.isTrue(isBlameLoadedStub.notCalled);
       });
-    });
-
-    test('loadBlame empty', async () => {
-      const mockBlame: BlameInfo[] = [];
-      const showAlertStub = sinon.stub();
-      const isBlameLoadedStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
-      stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
-      const changeNum = 42 as NumericChangeId;
-      element.changeNum = changeNum;
-      element.patchRange = createPatchRange();
-      element.path = 'foo/bar.baz';
-      await element.updateComplete;
-      return element
-        .loadBlame()
-        .then(() => {
-          assert.isTrue(false, 'Promise should not resolve');
-        })
-        .catch(() => {
-          assert.isTrue(showAlertStub.calledOnce);
-          assert.isNull(element.blame);
-          // We don't expect a call because
-          assert.isTrue(isBlameLoadedStub.notCalled);
-        });
-    });
   });
 
   test('delegates clearDiffContent()', () => {
@@ -769,12 +762,9 @@
     let reportStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
     setup(async () => {
-      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
-      element.changeNum = 123 as NumericChangeId;
-      element.path = 'file.txt';
       element.patchRange = createPatchRange(1, 2);
-      reportStub = sinon.stub(element.reporting, 'reportInteraction');
       await element.updateComplete;
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
       reportStub.reset();
     });
 
@@ -1390,24 +1380,18 @@
     ];
 
     setup(async () => {
-      coverageProviderStub = sinon
-        .stub()
-        .returns(Promise.resolve(exampleRanges));
-      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
-      element.changeNum = 123 as NumericChangeId;
-      element.change = createChange();
-      element.path = 'some/path';
-      const prefs = {
+      element.prefs = {
         ...createDefaultDiffPrefs(),
         line_length: 10,
         show_tabs: true,
         tab_size: 4,
         context: -1,
       };
-      element.patchRange = createPatchRange();
-      element.prefs = prefs;
       await element.updateComplete;
 
+      coverageProviderStub = sinon
+        .stub()
+        .returns(Promise.resolve(exampleRanges));
       getDiffRestApiStub.returns(
         Promise.resolve({
           ...createDiff(),
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 166444c..f228fb3 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
@@ -7,6 +7,7 @@
 import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {DiffViewMode} from '../../../constants/constants';
 import {customElement, property, state} from 'lit/decorators.js';
 import {fireIronAnnounce} from '../../../utils/event-util';
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 eed45db..8e6486e 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
@@ -47,10 +47,11 @@
   PreferencesInfo,
   RepoName,
   RevisionPatchSetNum,
+  Comment,
   CommentMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
-import {FileRange, ParsedChangeInfo} from '../../../types/types';
+import {ParsedChangeInfo} from '../../../types/types';
 import {
   FilesWebLinks,
   PatchRangeChangeEvent,
@@ -72,7 +73,11 @@
   ShortcutSection,
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
-import {DisplayLine, LineSelectedEventDetail} from '../../../api/diff';
+import {
+  DisplayLine,
+  FileRange,
+  LineSelectedEventDetail,
+} from '../../../api/diff';
 import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
@@ -232,6 +237,9 @@
   @state()
   leftSide = false;
 
+  @state()
+  commentsForPath: Comment[] = [];
+
   // visible for testing
   reviewedFiles = new Set<string>();
 
@@ -485,6 +493,7 @@
         :host {
           display: block;
           background-color: var(--view-background-color);
+          --sidebar-width: 300px;
         }
         .hidden {
           display: none;
@@ -499,9 +508,8 @@
           background-color: var(--view-background-color);
           position: sticky;
           top: 0;
-          /* TODO(dhruvsri): This is required only because of 'position:relative' in
-            <gr-diff-highlight> (which could maybe be removed??). */
-          z-index: 1;
+          /* sidebar should outrank <footer> in GrAppElement */
+          z-index: 110;
           box-shadow: var(--elevation-level-1);
           /* This is just for giving the box-shadow some space. */
           margin-bottom: 2px;
@@ -660,8 +668,12 @@
         :host(.hideComments) {
           --gr-comment-thread-display: none;
         }
+        .diffContainer.sidebarOpen {
+          margin-left: var(--sidebar-width);
+        }
         .sidebarTriggerContainer {
           display: inline-block;
+          margin-right: var(--spacing-m);
         }
         .sidebarAnchor {
           height: 0;
@@ -670,20 +682,11 @@
         }
         .sidebarContents {
           background: var(--background-color-secondary);
-          width: max-content;
+          width: var(--sidebar-width);
           padding: var(--spacing-l);
           border: var(--spacing-xs) solid var(--border-color);
           border-left: 0;
-          overflow-y: auto;
-          animation: slide-in 50ms;
-        }
-        @keyframes slide-in {
-          0% {
-            transform: translateX(-100%);
-          }
-          100% {
-            transform: translateX(0);
-          }
+          overflow: auto;
         }
       `,
     ];
@@ -766,6 +769,20 @@
         });
       }
     }
+    if (
+      (changedProperties.has('change') ||
+        changedProperties.has('changeComments') ||
+        changedProperties.has('path') ||
+        changedProperties.has('patchRange')) &&
+      this.changeComments !== undefined &&
+      this.path !== undefined &&
+      this.patchRange !== undefined
+    ) {
+      this.commentsForPath = this.changeComments.getCommentsForPath(
+        this.path,
+        this.patchRange
+      );
+    }
     this.updateSidebarHeight();
   }
 
@@ -778,25 +795,27 @@
     return html`
       ${this.renderStickyHeader()}
       <h2 class="assistive-tech-only">Diff view</h2>
-      <gr-diff-host
-        id="diffHost"
-        .changeNum=${this.changeNum}
-        .change=${this.change}
-        .patchRange=${this.patchRange}
-        .file=${file}
-        .lineOfInterest=${this.getLineOfInterest()}
-        .path=${this.path}
-        .projectName=${this.change?.project}
-        @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
-        @comment-anchor-tap=${this.onCommentAnchorTap}
-        @line-selected=${this.onLineSelected}
-        @diff-changed=${this.onDiffChanged}
-        @edit-weblinks-changed=${this.onEditWeblinksChanged}
-        @files-weblinks-changed=${this.onFilesWeblinksChanged}
-        @is-image-diff-changed=${this.onIsImageDiffChanged}
-        @render=${this.reInitCursor}
-      >
-      </gr-diff-host>
+      <div class="diffContainer ${this.shownSidebar && 'sidebarOpen'}">
+        <gr-diff-host
+          id="diffHost"
+          .changeNum=${this.changeNum}
+          .change=${this.change}
+          .patchRange=${this.patchRange}
+          .file=${file}
+          .lineOfInterest=${this.getLineOfInterest()}
+          .path=${this.path}
+          .projectName=${this.change?.project}
+          @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
+          @comment-anchor-tap=${this.onCommentAnchorTap}
+          @line-selected=${this.onLineSelected}
+          @diff-changed=${this.onDiffChanged}
+          @edit-weblinks-changed=${this.onEditWeblinksChanged}
+          @files-weblinks-changed=${this.onFilesWeblinksChanged}
+          @is-image-diff-changed=${this.onIsImageDiffChanged}
+          @render=${this.reInitCursor}
+        >
+        </gr-diff-host>
+      </div>
       ${this.renderDialogs()}
     `;
   }
@@ -853,7 +872,6 @@
             @value-change=${this.handleFileChange}
           ></gr-dropdown-list>
         </div>
-        ${this.renderSidebarTriggers()}
       </div>
       <div class="navLinks desktop">
         <span class="fileNum ${ifDefined(fileNumClass)}">
@@ -902,6 +920,11 @@
               (this.shownSidebar =
                 this.shownSidebar === pluginName ? undefined : pluginName)}
           ></gr-endpoint-param>
+          <!-- params cannot start falsy, so the value must be wrapped -->
+          <gr-endpoint-param
+            name="openSidebar"
+            .value=${{name: this.shownSidebar}}
+          ></gr-endpoint-param>
         </gr-endpoint-decorator>
       </div>
     `;
@@ -929,11 +952,24 @@
                   name="path"
                   .value=${this.path}
                 ></gr-endpoint-param>
+                <!-- current diff path and, in case of rename, previous path -->
+                <gr-endpoint-param
+                  name="fileRange"
+                  .value=${this.getFileRange()}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="basePatchNum"
+                  .value=${this.basePatchNum}
+                ></gr-endpoint-param>
                 <gr-endpoint-param
                   name="patchNum"
                   .value=${this.patchNum}
                 ></gr-endpoint-param>
                 <gr-endpoint-param
+                  name="content"
+                  .value=${this.diff}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
                   name="cursor"
                   .value=${this.cursor}
                 ></gr-endpoint-param>
@@ -941,6 +977,23 @@
                   name="diff"
                   .value=${this.diffHost?.diffElement}
                 ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="comments"
+                  .value=${this.commentsForPath}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="onClose"
+                  .value=${(pluginName: string) => {
+                    // Only close the sidebar if that particular sidebar is
+                    // still open. An async onClose callback should not close a
+                    // different sidebar.
+                    this.shownSidebar =
+                      this.shownSidebar === pluginName
+                        ? undefined
+                        : this.shownSidebar;
+                  }}
+                >
+                </gr-endpoint-param>
               </gr-endpoint-decorator>
             </div>
           `
@@ -978,6 +1031,7 @@
       this.isBlameLoaded && !this.isBlameLoading ? 'Hide blame' : 'Show blame';
     const diffModeSelectorClass = !this.diff || this.diff.binary ? 'hide' : '';
     return html` <div class="rightControls">
+      ${this.renderSidebarTriggers()}
       <span class="blameLoader ${blameLoaderClass}">
         <gr-button
           link=""
@@ -1091,6 +1145,10 @@
     }
   }
 
+  /**
+   * Returns the current file path and, if it was renamed in this change, the
+   * previous file path.
+   */
   private getFileRange() {
     if (!this.files || !this.path) return;
     const fileInfo = this.files.changeFilesByPath[this.path];
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 8562741..9c1c3ce 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
@@ -227,11 +227,6 @@
                   <gr-dropdown-list id="dropdown" show-copy-for-trigger-text="">
                   </gr-dropdown-list>
                 </div>
-                <div class="sidebarTriggerContainer">
-                  <gr-endpoint-decorator name="sidebarTrigger">
-                    <gr-endpoint-param name="onTrigger"></gr-endpoint-param>
-                  </gr-endpoint-decorator>
-                </div>
               </div>
               <div class="desktop navLinks">
                 <span class="fileNum show">
@@ -275,6 +270,12 @@
                 </span>
               </div>
               <div class="rightControls">
+                <div class="sidebarTriggerContainer">
+                  <gr-endpoint-decorator name="sidebarTrigger">
+                    <gr-endpoint-param name="onTrigger"></gr-endpoint-param>
+                    <gr-endpoint-param name="openSidebar"></gr-endpoint-param>
+                  </gr-endpoint-decorator>
+                </div>
                 <span class="blameLoader show">
                   <gr-button
                     aria-disabled="false"
@@ -356,7 +357,9 @@
             <div class="sidebarAnchor"></div>
           </div>
           <h2 class="assistive-tech-only">Diff view</h2>
-          <gr-diff-host id="diffHost"> </gr-diff-host>
+          <div class="diffContainer">
+            <gr-diff-host id="diffHost"> </gr-diff-host>
+          </div>
           <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
           <gr-diff-preferences-dialog id="diffPreferencesDialog">
           </gr-diff-preferences-dialog>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index a8bc84c..87fe27f 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -14,7 +14,10 @@
 import {customElement, state} from 'lit/decorators.js';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
-import {documentationViewModelToken} from '../../../models/views/documentation';
+import {
+  createDocumentationUrl,
+  documentationViewModelToken,
+} from '../../../models/views/documentation';
 
 @customElement('gr-documentation-search')
 export class GrDocumentationSearch extends LitElement {
@@ -57,7 +60,7 @@
       .filter=${this.filter}
       .offset=${0}
       .loading=${this.loading}
-      .path=${'/Documentation'}
+      .path=${createDocumentationUrl()}
     >
       <table id="list" class="genericList">
         <tbody>
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 5786112..c3b4e65 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
@@ -563,8 +563,6 @@
       }
 
       const fr = new FileReader();
-      // TODO(TS): Do we need this line?
-      // fr.file = file;
       fr.onload = (fileLoadEvent: ProgressEvent<FileReader>) => {
         if (!fileLoadEvent) return;
         const fileData = fileLoadEvent.target!.result;
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 824f4cb..647a2fb 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -28,11 +28,11 @@
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
 import './core/gr-notifications-prompt/gr-notifications-prompt';
-import {getBaseUrl} from '../utils/url-util';
+import {loginUrl} from '../utils/url-util';
 import {navigationToken} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
 import {routerToken} from './core/gr-router/gr-router';
-import {AccountDetailInfo, NumericChangeId} from '../types/common';
+import {AccountDetailInfo, NumericChangeId, ServerInfo} from '../types/common';
 import {
   constructServerErrorMsg,
   GrErrorManager,
@@ -75,6 +75,7 @@
 import {modalStyles} from '../styles/gr-modal-styles';
 import {AdminChildView, createAdminUrl} from '../models/views/admin';
 import {ChangeChildView, changeViewModelToken} from '../models/views/change';
+import {configModelToken} from '../models/config/config-model';
 
 interface ErrorInfo {
   text: string;
@@ -135,8 +136,6 @@
 
   @state() private mobileSearch = false;
 
-  @state() private loginUrl = '/login';
-
   @state() private loadRegistrationDialog = false;
 
   @state() private loadKeyboardShortcutsDialog = false;
@@ -153,6 +152,9 @@
 
   @state() private theme = AppTheme.AUTO;
 
+  @state()
+  serverConfig?: ServerInfo;
+
   readonly getRouter = resolve(this, routerToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
@@ -171,6 +173,8 @@
 
   private readonly getChangeViewModel = resolve(this, changeViewModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
 
@@ -183,9 +187,7 @@
     this.addEventListener('dialog-change', e => {
       this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    document.addEventListener('location-change', () =>
-      this.handleLocationChange()
-    );
+    document.addEventListener('location-change', () => this.requestUpdate());
     document.addEventListener('gr-rpc-log', e => this.handleRpcLog(e));
     this.shortcuts.addAbstract(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, () =>
       this.showKeyboardShortcuts()
@@ -220,6 +222,14 @@
 
     subscribe(
       this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+
+    subscribe(
+      this,
       () => this.getUserModel().preferenceTheme$,
       theme => {
         this.theme = theme;
@@ -259,7 +269,6 @@
     const resizeObserver = this.getBrowserModel().observeWidth();
     resizeObserver.observe(this);
 
-    this.updateLoginUrl();
     this.reporting.appStarted();
     this.getRouter().start();
 
@@ -308,11 +317,11 @@
           border-left: 0;
           border-top: 0;
           box-shadow: var(--header-box-shadow);
-          /* Make sure the header is above the main content, to preserve box-shadow
-            visibility. We need 2 here instead of 1, because dropdowns in the
+          /* Make sure the header is above the main content, to preserve
+            box-shadow visibility. We need 111 here 1, because dropdowns in the
             header should be shown on top of the sticky diff header, which has a
-            z-index of 1. */
-          z-index: 2;
+            z-index of 110. */
+          z-index: 111;
         }
         footer {
           background: var(
@@ -399,7 +408,8 @@
       <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
       <gr-error-manager
         id="errorManager"
-        .loginUrl=${this.loginUrl}
+        .loginUrl=${loginUrl(this.serverConfig?.auth)}
+        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
       ></gr-error-manager>
       <gr-plugin-host id="plugins"></gr-plugin-host>
     `;
@@ -410,11 +420,12 @@
     return html`
       <gr-main-header
         id="mainHeader"
-        .searchQuery=${(this.params as SearchViewState)?.query}
+        .searchQuery=${(this.params as SearchViewState)?.query ?? ''}
         @mobile-search=${this.mobileSearchToggle}
         @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
         .mobileSearchHidden=${!this.mobileSearch}
-        .loginUrl=${this.loginUrl}
+        .loginUrl=${loginUrl(this.serverConfig?.auth)}
+        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
         ?aria-hidden=${this.footerHeaderAriaHidden}
       >
       </gr-main-header>
@@ -690,35 +701,6 @@
     }
   }
 
-  private handleLocationChange() {
-    this.updateLoginUrl();
-  }
-
-  private updateLoginUrl() {
-    const baseUrl = getBaseUrl();
-    if (baseUrl) {
-      // Strip the canonical path from the path since needing canonical in
-      // the path is unneeded and breaks the url.
-      this.loginUrl =
-        baseUrl +
-        '/login/' +
-        encodeURIComponent(
-          '/' +
-            window.location.pathname.substring(baseUrl.length) +
-            window.location.search +
-            window.location.hash
-        );
-    } else {
-      this.loginUrl =
-        '/login/' +
-        encodeURIComponent(
-          window.location.pathname +
-            window.location.search +
-            window.location.hash
-        );
-    }
-  }
-
   // private but used in test
   paramsChanged() {
     const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 05ee9ed..6b97c85 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -19,11 +19,12 @@
 import {GrRouter, routerToken} from './core/gr-router/gr-router';
 import {resolve} from '../models/dependency';
 import {removeRequestDependencyListener} from '../test/common-test-setup';
+import {ReactiveElement} from 'lit';
 
 suite('gr-app callback tests', () => {
-  const handleLocationChangeSpy = sinon.spy(
-    GrAppElement.prototype,
-    <any>'handleLocationChange'
+  const requestUpdateStub = sinon.stub(
+    ReactiveElement.prototype,
+    'requestUpdate'
   );
   const dispatchLocationChangeEventSpy = sinon.spy(
     GrRouter.prototype,
@@ -34,9 +35,9 @@
     await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
   });
 
-  test("handleLocationChange in gr-app-element is called after dispatching 'location-change' event in gr-router", () => {
+  test("requestUpdate in reactive-element is called after dispatching 'location-change' event in gr-router", () => {
     dispatchLocationChangeEventSpy();
-    assert.isTrue(handleLocationChangeSpy.calledOnce);
+    assert.isTrue(requestUpdateStub.calledOnce);
   });
 });
 
diff --git a/polygerrit-ui/app/elements/lit/incremental-repeat.ts b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
index 695290c..bcfb682 100644
--- a/polygerrit-ui/app/elements/lit/incremental-repeat.ts
+++ b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
@@ -17,18 +17,24 @@
   initialCount: number;
   targetFrameRate?: number;
   startAt?: number;
-  // TODO: targetFramerate
+  endAt?: number;
 }
 
 interface RepeatState<T> {
   values: T[];
   mapFn?: (val: T, idx: number) => unknown;
   startAt: number;
+  endAt: number;
   incrementAmount: number;
   lastRenderedAt: number;
   targetFrameRate: number;
 }
 
+// This directive supports incrementally rendering a list of elements.
+// It only responds to updates to values (which forces a complete re-render) and
+// an update to endAt (which expands the list).
+// It currently does not support changes to mapFn, initialCount or startAt
+// unless values are also changed.
 class IncrementalRepeat<T> extends AsyncDirective {
   private children: {part: ChildPart; options: RepeatOptions<T>}[] = [];
 
@@ -36,11 +42,14 @@
 
   private state!: RepeatState<T>;
 
+  // Will render from `options.startAt` to `options.endAt`, up to
+  // `options.initialCount` elements.
   render(options: RepeatOptions<T>) {
-    const values = options.values.slice(
-      options.startAt ?? 0,
-      (options.startAt ?? 0) + options.initialCount
-    );
+    const start = options.startAt ?? 0;
+    const offset = start + options.initialCount;
+    const end =
+      options.endAt === undefined ? offset : Math.min(options.endAt, offset);
+    const values = options.values.slice(start, end);
     if (options.mapFn) {
       return values.map(options.mapFn);
     }
@@ -57,6 +66,7 @@
         values: options.values,
         mapFn: options.mapFn,
         startAt: options.initialCount,
+        endAt: options.endAt ?? options.values.length,
         incrementAmount: options.initialCount,
         lastRenderedAt: performance.now(),
         targetFrameRate: options.targetFrameRate ?? 30,
@@ -66,7 +76,19 @@
       );
     } else {
       this.updateParts();
+      // TODO: Deal with updates to startAt by removing children and then
+      // trimming the child where the new startAt falls into.
+      if ((options.endAt ?? options.values.length) >= this.state.endAt) {
+        this.state.endAt = options.endAt ?? options.values.length;
+        if (this.nextScheduledFrameWork) {
+          cancelAnimationFrame(this.nextScheduledFrameWork);
+        }
+        this.nextScheduledFrameWork = requestAnimationFrame(
+          this.animationFrameHandler
+        );
+      }
     }
+    // Render the first initial count.
     return this.render(options);
   }
 
@@ -92,6 +114,10 @@
   private nextScheduledFrameWork: number | undefined;
 
   private animationFrameHandler = () => {
+    if (this.state.startAt >= this.state.endAt) {
+      this.nextScheduledFrameWork = undefined;
+      return;
+    }
     const now = performance.now();
     const frameRate = 1000 / (now - this.state.lastRenderedAt);
     if (frameRate < this.state.targetFrameRate) {
@@ -109,13 +135,16 @@
       values: this.state.values,
       initialCount: this.state.incrementAmount,
       startAt: this.state.startAt,
+      endAt: this.state.endAt,
     });
 
     this.state.startAt += this.state.incrementAmount;
-    if (this.state.startAt < this.state.values.length) {
+    if (this.state.startAt < this.state.endAt) {
       this.nextScheduledFrameWork = requestAnimationFrame(
         this.animationFrameHandler
       );
+    } else {
+      this.nextScheduledFrameWork = undefined;
     }
   };
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 0c80a6a..598ae28 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -227,7 +227,7 @@
         <span class="value">
           <iron-autogrow-textarea
             id="statusInput"
-            .name=${'statusInput'}
+            .label=${'statusInput'}
             ?disabled=${this.saving}
             maxlength="140"
             .value=${this.account?.status}
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 7040285..8196201 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
@@ -4,10 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-avatar';
+import '../gr-hovercard-account/gr-hovercard-account';
 import {AccountInfo, ServerInfo} from '../../../types/common';
-import {LitElement, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {
+  isDetailedAccount,
   uniqueAccountId,
   uniqueDefinedAvatar,
 } from '../../../utils/account-util';
@@ -15,6 +17,9 @@
 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 {isDefined} from '../../../types/types';
+import {when} from 'lit/directives/when.js';
 
 /**
  * This elements draws stack of avatars overlapped with each other.
@@ -43,14 +48,22 @@
   imageSize = 16;
 
   /**
+   * Whether a hover-card should be shown for each avatar when hovered
+   */
+  @property({type: Boolean})
+  enableHover = false;
+
+  /**
    * In gr-app, gr-account-chip is in charge of loading a full account, so
    * avatars will be set. However, code-owners will create gr-avatars with a
    * bare account-id. To enable fetching of those avatars, a flag is added to
-   * gr-avatar that will disregard the absence of avatar urls.
+   * gr-avatar-stack that will fetch the accounts on demand
    */
   @property({type: Boolean})
   forceFetch = false;
 
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
+
   @state() config?: ServerInfo;
 
   static override get styles() {
@@ -81,6 +94,24 @@
     );
   }
 
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('accounts')) {
+      if (
+        this.forceFetch &&
+        this.accounts.length > 0 &&
+        this.accounts.some(a => !isDetailedAccount(a))
+      ) {
+        Promise.all(
+          this.accounts.map(account =>
+            this.getAccountsModel().fillDetails(account)
+          )
+        ).then(accounts => {
+          this.accounts = accounts.filter(isDefined);
+        });
+      }
+    }
+  }
+
   override render() {
     const uniqueAvatarAccounts = this.forceFetch
       ? this.accounts.filter(uniqueAccountId)
@@ -98,11 +129,17 @@
     return uniqueAvatarAccounts.map(
       account =>
         html`<gr-avatar
-          .forceFetch=${this.forceFetch}
           .account=${account}
           .imageSize=${this.imageSize}
           aria-label=${getDisplayName(this.config, account)}
         >
+          ${when(
+            this.enableHover,
+            () =>
+              html`<gr-hovercard-account
+                .account=${account}
+              ></gr-hovercard-account>`
+          )}
         </gr-avatar>`
     );
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
index 08066bf..8c1c5c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
@@ -12,6 +12,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {stubRestApi} from '../../../test/test-utils';
 import {LitElement} from 'lit';
+import {Timestamp} from '../../../api/rest-api';
 
 suite('gr-avatar tests', () => {
   suite('config with avatars', () => {
@@ -82,6 +83,104 @@
       }
     });
 
+    test('renders avatars and hovercards', async () => {
+      const accounts = [];
+      for (let i = 0; i < 2; ++i) {
+        accounts.push({
+          ...createAccountWithId(i),
+          avatars: [
+            {
+              url: `https://a.b.c/photo${i}.jpg`,
+              height: 32,
+              width: 32,
+            },
+          ],
+        });
+      }
+      accounts.push({
+        ...createAccountWithId(2),
+        avatars: [
+          {
+            // Account with duplicate avatar will be skipped.
+            url: 'https://a.b.c/photo1.jpg',
+            height: 32,
+            width: 32,
+          },
+        ],
+      });
+
+      const element: LitElement = await fixture(
+        html`<gr-avatar-stack
+          .accounts=${accounts}
+          .imageSize=${32}
+          .enableHover=${true}
+        ></gr-avatar-stack>`
+      );
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<gr-avatar
+            aria-label="0"
+            style='background-image: url("https://a.b.c/photo0.jpg");'
+          >
+            <gr-hovercard-account></gr-hovercard-account>
+          </gr-avatar>
+          <gr-avatar
+            aria-label="1"
+            style='background-image: url("https://a.b.c/photo1.jpg");'
+          >
+            <gr-hovercard-account></gr-hovercard-account>
+          </gr-avatar> `
+      );
+      // Verify that margins are set correctly.
+      const avatars = element.shadowRoot!.querySelectorAll('gr-avatar');
+      assert.strictEqual(avatars.length, 2);
+      assert.strictEqual(window.getComputedStyle(avatars[0]).marginLeft, '0px');
+      for (let i = 1; i < avatars.length; ++i) {
+        assert.strictEqual(
+          window.getComputedStyle(avatars[i]).marginLeft,
+          '-8px'
+        );
+      }
+    });
+
+    test('fetches account details. avatars', async () => {
+      const stub = stubRestApi('getAccountDetails').resolves({
+        ...createAccountWithId(1),
+        avatars: [
+          {
+            url: 'https://a.b.c/photo0.jpg',
+            height: 32,
+            width: 32,
+          },
+        ],
+        registered_on: '1234' as Timestamp,
+      });
+      const element: LitElement = await fixture(
+        html`<gr-avatar-stack
+          .accounts=${[{_account_id: 1}]}
+          .forceFetch=${true}
+          .imageSize=${32}
+        ></gr-avatar-stack>`
+      );
+      await element.updateComplete;
+
+      assert.equal(stub.called, true);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<gr-avatar
+          aria-label="1"
+          style='background-image: url("https://a.b.c/photo0.jpg");'
+        >
+        </gr-avatar>`
+      );
+      // Verify that margins are set correctly.
+      const avatars = element.shadowRoot!.querySelectorAll('gr-avatar');
+      assert.strictEqual(avatars.length, 1);
+      assert.strictEqual(window.getComputedStyle(avatars[0]).marginLeft, '0px');
+    });
+
     test('renders many accounts fallback', async () => {
       const accounts = [];
       for (let i = 0; i < 5; ++i) {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index 8cfe2d0..1ea2a64 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -25,14 +25,6 @@
 
   @state() private hasAvatars = false;
 
-  // In gr-app, gr-account-chip is in charge of loading a full account, so
-  // avatars will be set. However, code-owners will create gr-avatars with a
-  // bare account-id. To enable fetching of those avatars, a flag is added to
-  // gr-avatar that will disregard the absence of avatar urls.
-
-  @property({type: Boolean})
-  forceFetch = false;
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -98,7 +90,7 @@
     const avatars = account.avatars || [];
     // if there is no avatar url in account, there is no avatar set on server,
     // and request /avatar?s will be 404.
-    if (avatars.length === 0 && !this.forceFetch) {
+    if (avatars.length === 0) {
       return '';
     }
     for (let i = 0; i < avatars.length; i++) {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index b44a16b..49d3c7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-icon/gr-icon';
 import '@polymer/paper-button/paper-button';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {votingStyles} from '../../../styles/gr-voting-styles';
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 54fb825..d00d54f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-icon/gr-icon';
 import {ChangeInfo} from '../../../types/common';
 import {
   Shortcut,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-model/gr-comment-model.ts b/polygerrit-ui/app/elements/shared/gr-comment-model/gr-comment-model.ts
new file mode 100644
index 0000000..974329a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-model/gr-comment-model.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {define} from '../../../models/dependency';
+import {Model} from '../../../models/model';
+import {isDefined} from '../../../types/types';
+import {select} from '../../../utils/observable-util';
+import {Comment} from '../../../types/common';
+
+export interface CommentState {
+  comment: Comment;
+  commentedText?: string;
+}
+
+export const commentModelToken = define<CommentModel>('diff-model');
+
+export class CommentModel extends Model<CommentState | undefined> {
+  readonly comment$: Observable<Comment> = select(
+    this.state$.pipe(filter(isDefined)),
+    commentState => commentState.comment
+  );
+
+  readonly commentedText$: Observable<string | undefined> = select(
+    this.state$.pipe(filter(isDefined)),
+    commentState => commentState.commentedText
+  );
+}
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 30576c8..24777fb 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
@@ -315,6 +315,8 @@
           font-size: var(--font-size-normal);
           font-weight: var(--font-weight-normal);
           line-height: var(--line-height-normal);
+        }
+        gr-diff#diff {
           /* Explicitly set the background color of the diff. We
            * cannot use the diff content type ab because of the skip chunk preceding
            * it, diff processor assumes the chunk of type skip/ab can be collapsed
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 1d2b39e..d27be9f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -17,7 +17,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {resolve} from '../../../models/dependency';
+import {provide, resolve} from '../../../models/dependency';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
 import {
   AccountDetailInfo,
@@ -31,9 +31,11 @@
   isError,
   isDraft,
   isNew,
+  CommentInput,
 } from '../../../types/common';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
+  convertToCommentInput,
   createUserFixSuggestion,
   getContentInCommentRange,
   getUserSuggestion,
@@ -64,6 +66,12 @@
 import {createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import {
+  CommentModel,
+  commentModelToken,
+} from '../gr-comment-model/gr-comment-model';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -167,6 +175,10 @@
   @property({type: Boolean, attribute: 'permanent-editing-mode'})
   permanentEditingMode = false;
 
+  // Whether to disable autosaving
+  @property({type: Boolean})
+  disableAutoSaving = false;
+
   @state()
   autoSaving?: Promise<DraftInfo>;
 
@@ -212,8 +224,14 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly shortcuts = new ShortcutController(this);
 
+  private commentModel = new CommentModel(undefined);
+
   /**
    * This is triggered when the user types into the editing textarea. We then
    * debounce it and call autoSave().
@@ -234,6 +252,7 @@
 
   constructor() {
     super();
+    provide(this, commentModelToken, () => this.commentModel);
     // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
     // them as well.
     this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
@@ -767,8 +786,8 @@
     return html`
       <div class="rightActions">
         ${this.renderDiscardButton()} ${this.renderEditButton()}
-        ${this.renderCancelButton()} ${this.renderSaveButton()}
-        ${this.renderCopyLinkIcon()}
+        ${this.renderGenerateSuggestEditButton()} ${this.renderCancelButton()}
+        ${this.renderSaveButton()} ${this.renderCopyLinkIcon()}
       </div>
     `;
   }
@@ -841,6 +860,38 @@
     `;
   }
 
+  // TODO(milutin): This is temporary solution for experimenting
+  private renderGenerateSuggestEditButton() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT)) {
+      return nothing;
+    }
+    return html`
+      <gr-button link class="action" @click=${this.generateSuggestEdit}
+        >Suggestion</gr-button
+      >
+    `;
+  }
+
+  // TODO(milutin): This is temporary solution for experimenting
+  private async generateSuggestEdit() {
+    const suggestionsPlugins =
+      this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+    if (suggestionsPlugins.length === 0) return;
+    if (!this.changeNum || !this.comment?.patch_set || !this.comments?.[0].path)
+      return;
+    const suggestion = await suggestionsPlugins[0].provider.suggestCode({
+      prompt: this.messageText,
+      changeNumber: this.changeNum,
+      patchsetNumber: this.comment?.patch_set,
+      filePath: this.comments?.[0].path,
+      range: this.comments?.[0].range,
+      lineNumber: this.comments?.[0].line,
+    });
+    const replacement = suggestion.suggestions?.[0].replacement;
+    if (!replacement) return;
+    this.messageText += `${USER_SUGGESTION_START_PATTERN}${suggestion}${'\n```'}`;
+  }
+
   private renderRobotActions() {
     if (!this.account || !isRobot(this.comment)) return;
     const endpoint = html`
@@ -935,6 +986,22 @@
         whenVisible(this, () => this.textarea?.putCursorAtEnd());
       }
     }
+    if (changed.has('changeNum') || changed.has('comment')) {
+      if (
+        !this.flagsService.isEnabled(
+          KnownExperimentId.DIFF_FOR_USER_SUGGESTED_EDIT
+        ) ||
+        !this.changeNum ||
+        !this.comment
+      )
+        return;
+      (async () => {
+        const commentedText = await this.getCommentedCode();
+        this.commentModel.updateState({
+          commentedText,
+        });
+      })();
+    }
   }
 
   override willUpdate(changed: PropertyValues) {
@@ -943,6 +1010,11 @@
       if (isDraft(this.comment) && isError(this.comment)) {
         this.edit();
       }
+      if (this.comment) {
+        this.commentModel.updateState({
+          comment: this.comment,
+        });
+      }
     }
     if (changed.has('editing')) {
       this.onEditingChanged();
@@ -1149,6 +1221,7 @@
   async autoSave() {
     if (isSaving(this.comment) || this.autoSaving) return;
     if (!this.editing || !this.comment) return;
+    if (this.disableAutoSaving) return;
     assert(isDraft(this.comment), 'only drafts are editable');
     const messageToSave = this.messageText.trimEnd();
     if (messageToSave === '') return;
@@ -1167,6 +1240,15 @@
     await this.save();
   }
 
+  convertToCommentInput(): CommentInput | undefined {
+    if (!this.somethingToSave() || !this.comment) return;
+    return convertToCommentInput({
+      ...this.comment,
+      message: this.messageText.trimEnd(),
+      unresolved: this.unresolved,
+    });
+  }
+
   async save() {
     assert(isDraft(this.comment), 'only drafts are editable');
     // There is a minimal chance of `isSaving()` being false between iterations
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index ec15e12..e6e27bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -6,6 +6,7 @@
 import '@polymer/iron-input/iron-input';
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
+import '../gr-tooltip-content/gr-tooltip-content';
 import {
   assertIsDefined,
   copyToClipbard,
@@ -106,7 +107,6 @@
               id="input"
               is="iron-input"
               class=${classMap({hideInput: this.hideInput})}
-              type="text"
               @click=${this._handleInputClick}
               readonly=""
               .value=${this.text ?? ''}
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 9b74e24..b6af123 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -171,7 +171,7 @@
         }
         @media only screen and (max-width: 50em) {
           gr-select {
-            display: var(--gr-select-style-display, inline);
+            display: var(--gr-select-style-display, inline-block);
             width: var(--gr-select-style-width);
           }
           gr-button,
@@ -187,7 +187,7 @@
   }
 
   protected override willUpdate(changedProperties: PropertyValues): void {
-    if (changedProperties.has('items') || changedProperties.has('value')) {
+    if (changedProperties.has('value')) {
       this.handleValueChange();
     }
   }
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 c6bfb74..e810637 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?://\\S+[\\w/])',
+          match: '(https?://\\S+[\\w/~-])',
           link: '$1',
           enabled: true,
         };
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 7bc0b11..88866d4 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
@@ -14,11 +14,15 @@
 import {getAppContext} from '../../../services/app-context';
 import './gr-formatted-text';
 import {GrFormattedText} from './gr-formatted-text';
-import {createConfig} from '../../../test/test-data-generators';
+import {createComment, createConfig} from '../../../test/test-data-generators';
 import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
+import {
+  CommentModel,
+  commentModelToken,
+} from '../gr-comment-model/gr-comment-model';
 
 suite('gr-formatted-text tests', () => {
   let element: GrFormattedText;
@@ -37,6 +41,7 @@
       testResolver(changeModelToken),
       getAppContext().restApiService
     );
+    const commentModel = new CommentModel({comment: createComment()});
     await setCommentLinks({
       customLinkRewrite: {
         match: '(LinkRewriteMe)',
@@ -54,9 +59,13 @@
     element = (
       await fixture(
         wrapInProvider(
-          html`<gr-formatted-text></gr-formatted-text>`,
-          configModelToken,
-          configModel
+          wrapInProvider(
+            html`<gr-formatted-text></gr-formatted-text>`,
+            configModelToken,
+            configModel
+          ),
+          commentModelToken,
+          commentModel
         )
       )
     ).querySelector('gr-formatted-text')!;
@@ -210,35 +219,19 @@
     });
 
     test('does default linking', async () => {
-      element.content = 'http://www.google.com';
-      await element.updateComplete;
-      assert.shadowDom.equal(
-        element,
-        /* HTML*/ `
-        <pre class="plaintext">
-          <a
-            href="http://www.google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >http://www.google.com</a>
-        </pre>
-      `
-      );
+      const checkLinking = async (url: string) => {
+        element.content = url;
+        await element.updateComplete;
+        const a = queryAndAssert<HTMLElement>(element, 'a');
+        assert.equal(a.getAttribute('href'), url);
+        assert.equal(a.innerText, url);
+      };
 
-      element.content = 'https://www.google.com';
-      await element.updateComplete;
-      assert.shadowDom.equal(
-        element,
-        /* HTML*/ `
-        <pre class="plaintext">
-          <a
-            href="https://www.google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >https://www.google.com</a>
-        </pre>
-        `
-      );
+      await checkLinking('http://www.google.com');
+      await checkLinking('https://www.google.com');
+      await checkLinking('https://www.google.com/');
+      await checkLinking('https://www.google.com/asdf~');
+      await checkLinking('https://www.google.com/asdf-');
     });
   });
 
@@ -678,43 +671,18 @@
     });
 
     test('does default linking', async () => {
-      element.content = 'http://www.google.com';
-      await element.updateComplete;
-      assert.shadowDom.equal(
-        element,
-        /* HTML*/ `
-        <marked-element>
-          <div slot="markdown-html" class="markdown-html">
-            <p>
-              <a
-                href="http://www.google.com"
-                rel="noopener noreferrer"
-                target="_blank"
-              >http://www.google.com</a>
-            </p>
-          </div>
-        </marked-element>
-      `
-      );
+      const checkLinking = async (url: string) => {
+        element.content = url;
+        await element.updateComplete;
+        const a = queryAndAssert<HTMLElement>(element, 'a');
+        const p = queryAndAssert<HTMLElement>(element, 'p');
+        assert.equal(a.getAttribute('href'), url);
+        assert.equal(p.innerText, url);
+      };
 
-      element.content = 'https://www.google.com';
-      await element.updateComplete;
-      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"
-              >https://www.google.com</a>
-            </p>
-          </div>
-        </marked-element>
-        `
-      );
+      await checkLinking('http://www.google.com');
+      await checkLinking('https://www.google.com');
+      await checkLinking('https://www.google.com/');
     });
 
     suite('user suggest fix', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
index c2b6a33..ac6fa57 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -438,7 +438,7 @@
     this.restApiService
       .saveChangeReview(this.change._number, CURRENT, reviewInput)
       .then(response => {
-        if (!response || !response.ok) {
+        if (!response) {
           throw new Error(
             'something went wrong when toggling' +
               this.getReviewerState(this.change!)
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 7df06f4..c3c48cb 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
@@ -229,7 +229,7 @@
     };
     await element.updateComplete;
     const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
+      Promise.resolve({})
     );
     stubRestApi('removeChangeReviewer').returns(
       Promise.resolve({...new Response(), ok: true})
@@ -257,7 +257,7 @@
     };
     await element.updateComplete;
     const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
+      Promise.resolve({})
     );
     stubRestApi('removeChangeReviewer').returns(
       Promise.resolve({...new Response(), ok: true})
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
index a2d70bc3..7e716b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
@@ -49,6 +49,7 @@
           white-space: nowrap;
           word-wrap: normal;
           direction: ltr;
+          font-feature-settings: 'liga';
           -webkit-font-feature-settings: 'liga';
           -webkit-font-smoothing: antialiased;
           font-variation-settings: 'FILL' 0;
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 46c759b..cd73727 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
@@ -16,6 +16,7 @@
   JsApiService,
   EventCallback,
   ShowChangeDetail,
+  ShowDiffDetail,
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../../api/plugin';
@@ -240,6 +241,21 @@
     return review;
   }
 
+  async handleShowDiff(detail: ShowDiffDetail): Promise<void> {
+    await this.waitForPluginsToLoad();
+    for (const cb of this._getEventCallbacks(EventType.SHOW_DIFF)) {
+      try {
+        cb(detail.change, detail.patchRange, detail.fileRange);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('showDiff callback error'),
+          err
+        );
+      }
+    }
+  }
+
   _getEventCallbacks(type: EventType) {
     return eventCallbacks[type] || [];
   }
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 8e3a87d..afa16b9 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
@@ -15,6 +15,7 @@
 import {EventType, TargetElement} from '../../../api/plugin';
 import {ParsedChangeInfo} from '../../../types/types';
 import {MenuLink} from '../../../api/admin';
+import {FileRange, PatchRange} from '../../../api/diff';
 
 export interface ShowChangeDetail {
   change?: ParsedChangeInfo;
@@ -23,6 +24,12 @@
   info: {mergeable: boolean | null};
 }
 
+export interface ShowDiffDetail {
+  change: ChangeInfo;
+  patchRange: PatchRange;
+  fileRange: FileRange;
+}
+
 export interface ShowRevisionActionsDetail {
   change: ChangeInfo;
   revisionActions: {[key: string]: ActionInfo | undefined};
@@ -52,4 +59,5 @@
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
   getReviewPostRevert(change?: ChangeInfo): ReviewInput;
+  handleShowDiff(detail: ShowDiffDetail): void;
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index ea45142..14b5d14 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -47,6 +47,7 @@
   @property({type: Boolean})
   loading?: boolean;
 
+  /** Must include the base path. */
   @property({type: String})
   path?: string;
 
@@ -191,7 +192,8 @@
     // Offset could be a string when passed from the router.
     const offset = +(this.offset || 0);
     const newOffset = Math.max(0, offset + this.itemsPerPage * direction);
-    let href = getBaseUrl() + (this.path ?? '');
+    // Note that `this.path` already includes the base URL, if set and non-empty;
+    let href = this.path || getBaseUrl();
     if (this.filter) {
       href += '/q/filter:' + encodeURL(this.filter);
     }
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 38f4f17..5b1e162 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
@@ -61,23 +61,29 @@
     element.offset = 25;
     element.itemsPerPage = 25;
     element.filter = 'test';
-    element.path = '/admin/projects';
+    element.path = '/base/admin/projects';
 
-    stubBaseUrl('');
+    stubBaseUrl('/base');
 
-    assert.equal(element.computeNavLink(1), '/admin/projects/q/filter:test,50');
+    assert.equal(
+      element.computeNavLink(1),
+      '/base/admin/projects/q/filter:test,50'
+    );
 
-    assert.equal(element.computeNavLink(-1), '/admin/projects/q/filter:test');
+    assert.equal(
+      element.computeNavLink(-1),
+      '/base/admin/projects/q/filter:test'
+    );
 
     element.filter = undefined;
-    assert.equal(element.computeNavLink(1), '/admin/projects,50');
+    assert.equal(element.computeNavLink(1), '/base/admin/projects,50');
 
-    assert.equal(element.computeNavLink(-1), '/admin/projects');
+    assert.equal(element.computeNavLink(-1), '/base/admin/projects');
 
     element.filter = 'plugins/';
     assert.equal(
       element.computeNavLink(1),
-      '/admin/projects/q/filter:plugins/,50'
+      '/base/admin/projects/q/filter:plugins/,50'
     );
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index 1adc827..354a6b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -82,8 +82,9 @@
   /**
    * Removes messages that describe removed reviewers, since reviewer_updates
    * are used.
+   * Private but used in tests.
    */
-  private _filterRemovedMessages() {
+  _filterRemovedMessages() {
     this.result.messages = this.result.messages.filter(
       message => message.tag !== MessageTag.TAG_DELETE_REVIEWER
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
index 4225173..b875441 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -9,8 +9,6 @@
 import {assert} from '@open-wc/testing';
 
 suite('gr-reviewer-updates-parser tests', () => {
-  let instance;
-
   test('ignores changes without messages', () => {
     const change = {};
     sinon.stub(
@@ -80,7 +78,7 @@
         },
       ],
     };
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._filterRemovedMessages();
     assert.deepEqual(instance.result, {
       messages: [{
@@ -122,7 +120,7 @@
       ],
     };
 
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._groupUpdates();
     change = instance.result;
 
@@ -202,7 +200,7 @@
       ],
     };
 
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._formatUpdates();
 
     assert.equal(change.reviewer_updates.length, 2);
@@ -264,7 +262,7 @@
         message: 'Uploaded patch set 2.',
       }],
     };
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._advanceUpdates();
     const updates = instance.result.reviewer_updates;
     assert.isBelow(parseDate(updates[0].date).getTime(), T0);
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 2b1ad9e..5e67a88b 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
@@ -3,9 +3,34 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {css, html, LitElement, nothing} from 'lit';
-import {customElement} from 'lit/decorators.js';
+import '../../../embed/diff/gr-diff/gr-diff';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {getAppContext} from '../../../services/app-context';
+import {Comment} from '../../../types/common';
 import {fire} from '../../../utils/event-util';
+import {anyLineTooLong} from '../../../utils/diff-util';
+import {
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  RenderPreferences,
+} from '../../../api/diff';
+import {when} from 'lit/directives/when.js';
+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 {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 {userModelToken} from '../../../models/user/user-model';
+import {createUserFixSuggestion} from '../../../utils/comment-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {commentModelToken} from '../gr-comment-model/gr-comment-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -20,7 +45,78 @@
 }
 
 @customElement('gr-user-suggestion-fix')
-export class GrUserSuggetionFix extends LitElement {
+export class GrUserSuggestionsFix extends LitElement {
+  @state()
+  comment?: Comment;
+
+  @state()
+  commentedText?: string;
+
+  @state()
+  layers: DiffLayer[] = [];
+
+  @state()
+  previewLoaded = false;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  preview?: FilePreview;
+
+  @state()
+  diffPrefs?: DiffPreferencesInfo;
+
+  @state()
+  renderPrefs: RenderPreferences = {
+    disable_context_control_buttons: true,
+    show_file_comment_button: false,
+    hide_line_length_indicator: true,
+  };
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getCommentModel = resolve(this, commentModelToken);
+
+  private readonly flagsService = getAppContext().flagsService;
+
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.diffPrefs = diffPreferences;
+        this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentModel().comment$,
+      comment => (this.comment = comment)
+    );
+    subscribe(
+      this,
+      () => this.getCommentModel().commentedText$,
+      commentedText => (this.commentedText = commentedText)
+    );
+  }
+
   static override get styles() {
     return [
       css`
@@ -60,6 +156,19 @@
     ];
   }
 
+  override updated(changed: PropertyValues) {
+    if (changed.has('commentedText') || changed.has('comment')) {
+      if (
+        this.flagsService.isEnabled(
+          KnownExperimentId.DIFF_FOR_USER_SUGGESTED_EDIT
+        ) &&
+        !this.previewLoaded
+      ) {
+        this.fetchFixPreview();
+      }
+    }
+  }
+
   override render() {
     if (!this.textContent) return nothing;
     const code = this.textContent;
@@ -92,17 +201,79 @@
           </gr-button>
         </div>
       </div>
-      <code>${code}</code>`;
+      ${when(
+        this.previewLoaded,
+        () => this.renderDiff(),
+        () => html`<code>${code}</code>`
+      )} `;
   }
 
   handleShowFix() {
     if (!this.textContent) return;
     fire(this, 'open-user-suggest-preview', {code: this.textContent});
   }
+
+  private renderDiff() {
+    if (!this.preview) return;
+    const diff = this.preview.preview;
+    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>`;
+  }
+
+  private async fetchFixPreview() {
+    if (
+      !this.changeNum ||
+      !this.comment?.patch_set ||
+      !this.textContent ||
+      !this.commentedText
+    )
+      return;
+
+    const fixSuggestions = createUserFixSuggestion(
+      this.comment,
+      this.commentedText,
+      this.textContent
+    );
+    const res = await this.restApiService.getFixPreview(
+      this.changeNum,
+      this.comment?.patch_set,
+      fixSuggestions[0].replacements
+    );
+    if (res) {
+      const currentPreviews = Object.keys(res).map(key => {
+        return {filepath: key, preview: res[key]};
+      });
+      if (currentPreviews.length > 0) {
+        this.preview = currentPreviews[0];
+        this.previewLoaded = true;
+      }
+    }
+
+    return res;
+  }
+
+  private overridePartialDiffPrefs() {
+    if (!this.diffPrefs) return undefined;
+    return {
+      ...this.diffPrefs,
+      context: 0,
+      line_length: Math.min(this.diffPrefs.line_length, 100),
+      line_wrapping: true,
+    };
+  }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-user-suggestion-fix': GrUserSuggetionFix;
+    'gr-user-suggestion-fix': GrUserSuggestionsFix;
   }
 }
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 50bfebf..423fe62 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
@@ -6,18 +6,28 @@
 import '../../../test/common-test-setup';
 import './gr-user-suggestion-fix';
 import {fixture, html, assert} from '@open-wc/testing';
-import {GrUserSuggetionFix} from './gr-user-suggestion-fix';
-import {getAppContext} from '../../../services/app-context';
+import {GrUserSuggestionsFix} from './gr-user-suggestion-fix';
+import {
+  CommentModel,
+  commentModelToken,
+} from '../gr-comment-model/gr-comment-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {createComment} from '../../../test/test-data-generators';
 
 suite('gr-user-suggestion-fix tests', () => {
-  let element: GrUserSuggetionFix;
+  let element: GrUserSuggestionsFix;
 
   setup(async () => {
-    const flagsService = getAppContext().flagsService;
-    sinon.stub(flagsService, 'isEnabled').returns(true);
-    element = await fixture<GrUserSuggetionFix>(html`
-      <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
-    `);
+    const commentModel = new CommentModel({comment: createComment()});
+    element = (
+      await fixture<GrUserSuggestionsFix>(
+        wrapInProvider(
+          html` <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix> `,
+          commentModelToken,
+          commentModel
+        )
+      )
+    ).querySelector<GrUserSuggestionsFix>('gr-user-suggestion-fix')!;
     await element.updateComplete;
   });
 
@@ -45,7 +55,13 @@
             ></gr-copy-clipboard>
           </div>
           <div>
-            <gr-button class="action show-fix" secondary="" flatten=""
+            <gr-button
+              aria-disabled="false"
+              class="action show-fix"
+              secondary=""
+              role="button"
+              tabindex="0"
+              flatten=""
               >Show edit</gr-button
             >
           </div>
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-styles.ts
index e7f4b51..a6dddd6 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-styles.ts
@@ -658,7 +658,7 @@
   gr-selection-action-box {
     /* Needs z-index to appear above wrapped content, since it's inserted
        into DOM before it. */
-    z-index: 10;
+    z-index: 120;
   }
 
   gr-diff-image-new,
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 01661fe..a9e332a 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
@@ -210,7 +210,12 @@
       token: newHighlight,
       element,
     } = this.findTokenAncestor(e?.target);
-    if (!newHighlight || newHighlight === this.currentHighlight) return;
+    if (
+      !newHighlight ||
+      (this.currentHighlight === newHighlight &&
+        this.currentHighlightLineNumber === line)
+    )
+      return;
     this.hoveredElement = element;
     this.updateTokenTask = debounce(
       this.updateTokenTask,
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 e7f4b51..1b908cd 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
@@ -461,6 +461,9 @@
     color: var(--link-color);
     padding: var(--spacing-m) 0 var(--spacing-m) 48px;
   }
+  /* for new diff */
+  gr-diff-element,
+  /* for old diff, TODO: remove */
   #diffTable {
     /* for gr-selection-action-box positioning */
     position: relative;
@@ -658,7 +661,7 @@
   gr-selection-action-box {
     /* Needs z-index to appear above wrapped content, since it's inserted
        into DOM before it. */
-    z-index: 10;
+    z-index: 120;
   }
 
   gr-diff-image-new,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index c41dc91..7e30581 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -226,7 +226,7 @@
     return FULL_CONTEXT;
   }
   if (
-    prefsContext &&
+    prefsContext !== undefined &&
     !(showFullContext === FullContext.NO && prefsContext === FULL_CONTEXT)
   ) {
     return prefsContext;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 44f4f60..f425e2b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -196,6 +196,12 @@
       assert.equal(computeContext(1, FullContext.UNDECIDED, 2), 1);
     });
 
+    test('computeContext 0', () => {
+      assert.equal(computeContext(0, FullContext.YES, 2), FULL_CONTEXT);
+      assert.equal(computeContext(0, FullContext.NO, 2), 0);
+      assert.equal(computeContext(0, FullContext.UNDECIDED, 2), 0);
+    });
+
     test('computeContext FULL_CONTEXT', () => {
       assert.equal(
         computeContext(FULL_CONTEXT, FullContext.YES, 2),
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 ec6ed03..5d23da1 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -162,6 +162,7 @@
   @property({type: Boolean})
   lineWrapping = false;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: String})
   viewMode = DiffViewMode.SIDE_BY_SIDE;
 
@@ -212,12 +213,15 @@
   @property({type: Array})
   blame: BlameInfo[] | null = null;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: Boolean})
   showNewlineWarningLeft = false;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: Boolean})
   showNewlineWarningRight = false;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: Boolean})
   useNewImageDiffUi = false;
 
@@ -330,14 +334,31 @@
       changedProperties.has('path') ||
       changedProperties.has('renderPrefs') ||
       changedProperties.has('viewMode') ||
+      changedProperties.has('loggedIn') ||
+      changedProperties.has('useNewImageDiffUi') ||
+      changedProperties.has('showNewlineWarningLeft') ||
+      changedProperties.has('showNewlineWarningRight') ||
       changedProperties.has('prefs') ||
       changedProperties.has('lineOfInterest')
     ) {
       if (this.diff && this.prefs) {
         const renderPrefs = {...(this.renderPrefs ?? {})};
+        // TODO: Migrate users to using render preferences directly. Then removes these overrides.
         if (renderPrefs.view_mode === undefined) {
           renderPrefs.view_mode = this.viewMode;
         }
+        if (renderPrefs.can_comment === undefined) {
+          renderPrefs.can_comment = this.loggedIn;
+        }
+        if (renderPrefs.use_new_image_diff_ui === undefined) {
+          renderPrefs.use_new_image_diff_ui = this.useNewImageDiffUi;
+        }
+        if (renderPrefs.show_newline_warning_left === undefined) {
+          renderPrefs.show_newline_warning_left = this.showNewlineWarningLeft;
+        }
+        if (renderPrefs.show_newline_warning_right === undefined) {
+          renderPrefs.show_newline_warning_right = this.showNewlineWarningRight;
+        }
         this.diffModel.updateState({
           diff: this.diff,
           path: this.path,
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index b13a16f..e82c262 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -22,6 +22,7 @@
   ReviewerInput,
   AttentionSetInput,
   RelatedChangeAndCommitInfo,
+  ReviewResult,
 } from '../../types/common';
 import {getUserId} from '../../utils/account-util';
 import {getChangeNumber} from '../../utils/change-util';
@@ -164,7 +165,7 @@
   addReviewers(
     changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
     reason: string
-  ): Promise<Response>[] {
+  ): Promise<ReviewResult | undefined>[] {
     const current = this.getState();
     const changes = current.selectedChangeNums.map(
       changeNum => current.allChanges.get(changeNum)!
@@ -177,7 +178,7 @@
         this.getNewReviewersToChange(change, state, changedReviewers)
       );
       if (reviewersNewToChange.length === 0) {
-        return Promise.resolve(new Response());
+        return Promise.resolve(undefined);
       }
       const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
         .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
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 e2f75f7..ece126c 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
@@ -261,9 +261,7 @@
     let saveChangeReviewStub: sinon.SinonStub;
 
     setup(async () => {
-      saveChangeReviewStub = stubRestApi('saveChangeReview').resolves(
-        new Response()
-      );
+      saveChangeReviewStub = stubRestApi('saveChangeReview').resolves({});
       stubRestApi('getDetailedChangesWithActions').resolves([
         {...changes[0], actions: {abandon: {method: HttpMethod.POST}}},
         {...changes[1], status: ChangeStatus.ABANDONED},
diff --git a/polygerrit-ui/app/models/change/related-changes-model_test.ts b/polygerrit-ui/app/models/change/related-changes-model_test.ts
index 295f284..0bf7a76 100644
--- a/polygerrit-ui/app/models/change/related-changes-model_test.ts
+++ b/polygerrit-ui/app/models/change/related-changes-model_test.ts
@@ -270,7 +270,8 @@
         messages: [
           {
             ...createChangeMessage(),
-            message: 'Created a revert of this change as 123',
+            message:
+              'Created a revert of this change as If02ca1cd494579d6bb92a157bf1819e3689cd6b1',
             tag: MessageTag.TAG_REVERT as ReviewInputTag,
           },
         ],
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index c9b0bd9..7bf2785 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -23,6 +23,7 @@
 } from '../../types/common';
 import {
   addPath,
+  convertToCommentInput,
   createNew,
   createNewPatchsetLevel,
   id,
@@ -438,7 +439,6 @@
     private readonly navigation: NavigationService
   ) {
     super(initialState);
-    console.info('CommentsModel constrcutor');
     this.subscriptions.push(
       this.savingInProgress$.subscribe(savingInProgress => {
         if (savingInProgress) {
@@ -478,7 +478,6 @@
     );
     this.subscriptions.push(
       this.changeViewModel.changeNum$.subscribe(changeNum => {
-        console.info(`CommentsModel reload ${changeNum}`);
         this.changeNum = changeNum;
         this.setState({...initialState});
         this.reloadAllComments();
@@ -520,18 +519,8 @@
     this.setState(reducer({...this.getState()}));
   }
 
-  override setState(state: CommentState) {
-    const commentsUndefPrev = this.getState().comments === undefined;
-    const commentsUndefNext = state.comments === undefined;
-    console.info(
-      `CommentsModel setState ${commentsUndefPrev} ${commentsUndefNext} ${this.stateUpdateInProgress}`
-    );
-    super.setState(state);
-  }
-
   async reloadComments(changeNum: NumericChangeId): Promise<void> {
     const comments = await this.restApiService.getDiffComments(changeNum);
-    console.info(`CommentsModel setComments ${comments === undefined}`);
     this.modifyState(s => setComments(s, comments));
   }
 
@@ -650,7 +639,7 @@
       const result = await this.restApiService.saveDiffDraft(
         changeNum,
         draft.patch_set,
-        draft
+        convertToCommentInput(draft)
       );
       if (changeNum !== this.changeNum) return draft;
       if (!result.ok) throw new Error('request failed');
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
index 2d0ff42..19b52fc 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/model.ts
@@ -27,7 +27,7 @@
    * another `next()` call. So make sure that state updates complete before
    * starting another one.
    */
-  protected stateUpdateInProgress = false;
+  private stateUpdateInProgress = false;
 
   private subject$: BehaviorSubject<T>;
 
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index fe2a817..1d38c17 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -13,6 +13,7 @@
 import {Model} from '../model';
 import {select} from '../../utils/observable-util';
 import {CoverageProvider, TokenHoverListener} from '../../api/annotation';
+import {SuggestionsProvider} from '../../api/suggestions';
 
 export interface CoveragePlugin {
   pluginName: string;
@@ -25,6 +26,11 @@
   config: ChecksApiConfig;
 }
 
+export interface SuggestionPlugin {
+  pluginName: string;
+  provider: SuggestionsProvider;
+}
+
 export interface TokenHoverListenerPlugin {
   pluginName: string;
   listener: TokenHoverListener;
@@ -48,6 +54,11 @@
   checksPlugins: ChecksPlugin[];
 
   /**
+   * List of plugins that have called suggestions().register().
+   */
+  suggestionsPlugins: SuggestionPlugin[];
+
+  /**
    * List of plugins that have called
    * annotationApi().addTokenHoverListener().
    */
@@ -77,6 +88,7 @@
     super({
       coveragePlugins: [],
       checksPlugins: [],
+      suggestionsPlugins: [],
       tokenHighlightPlugins: [],
     });
   }
@@ -113,6 +125,22 @@
     this.setState(nextState);
   }
 
+  suggestionsRegister(plugin: SuggestionPlugin) {
+    const nextState = {...this.getState()};
+    nextState.suggestionsPlugins = [...nextState.suggestionsPlugins];
+    const alreadyRegistered = nextState.suggestionsPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a suggestion provider. Ignored.`
+      );
+      return;
+    }
+    nextState.suggestionsPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
   tokenHoverListenerRegister(plugin: TokenHoverListenerPlugin) {
     const nextState = {...this.getState()};
     nextState.tokenHighlightPlugins = [...nextState.tokenHighlightPlugins];
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index 6789695..abb0f03 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
@@ -13,6 +14,10 @@
   filter: string;
 }
 
+export function createDocumentationUrl() {
+  return `${getBaseUrl()}/Documentation`;
+}
+
 export const documentationViewModelToken = define<DocumentationViewModel>(
   'documentation-view-model'
 );
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index e55c6d3..a8d4a3b 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -18,4 +18,6 @@
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
+  ML_SUGGESTED_EDIT = 'UiFeature__ml_suggested_edit',
+  DIFF_FOR_USER_SUGGESTED_EDIT = 'UiFeature__diff_for_user_suggested_edit',
 }
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 f6c3156..fc4123b 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
@@ -22,10 +22,7 @@
 import {getBaseUrl} from '../../utils/url-util';
 import {Finalizable} from '../registry';
 import {getParentIndex, isMergeParent} from '../../utils/patch-set-util';
-import {
-  ListChangesOption,
-  listChangesOptionsToHex,
-} from '../../utils/change-util';
+import {listChangesOptionsToHex} from '../../utils/change-util';
 import {assertNever, hasOwnProperty} from '../../utils/common-util';
 import {AuthService} from '../gr-auth/gr-auth';
 import {
@@ -119,6 +116,8 @@
   UrlEncodedCommentId,
   FixReplacementInfo,
   DraftInfo,
+  ListChangesOption,
+  ReviewResult,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -311,7 +310,9 @@
 
   finalize() {}
 
-  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
+  _fetchSharedCacheURL(
+    req: FetchJSONRequest
+  ): Promise<AccountDetailInfo | ParsedJSON | undefined> {
     // Cache is shared across instances
     return this._restApiHelper.fetchCacheURL(req);
   }
@@ -1027,7 +1028,7 @@
   /**
    * Construct the uri to get list of changes.
    *
-   * If options is undefined then default options (see _getChangesOptionsHex) is
+   * If options is undefined then default options (see getListChangesOptionsHex) is
    * used.
    */
   getRequestForGetChanges(
@@ -1036,7 +1037,7 @@
     offset?: 'n,z' | number,
     options?: string
   ) {
-    options = options || this._getChangesOptionsHex();
+    options = options || this.getListChangesOptionsHex();
     if (offset === 'n,z') {
       offset = 0;
     }
@@ -1061,7 +1062,7 @@
   /**
    * For every query fetches the matching changes.
    *
-   * If options is undefined then default options (see _getChangesOptionsHex) is
+   * If options is undefined then default options (see getListChangesOptionsHex) is
    * used.
    */
   getChangesForMultipleQueries(
@@ -1108,7 +1109,7 @@
   /**
    * Fetches changes that match the query.
    *
-   * If options is undefined then default options (see _getChangesOptionsHex) is
+   * If options is undefined then default options (see getListChangesOptionsHex) is
    * used.
    */
   getChanges(
@@ -1183,34 +1184,33 @@
     );
   }
 
-  getChangeDetail(
+  async getChangeDetail(
     changeNum?: NumericChangeId,
     errFn?: ErrorCallback,
     cancelCondition?: CancelConditionCallback
   ): Promise<ParsedChangeInfo | undefined> {
-    if (!changeNum) return Promise.resolve(undefined);
-    return this.getConfig(false).then(config => {
-      const optionsHex = this._getChangeOptionsHex(config);
-      return this._getChangeDetail(
-        changeNum,
-        optionsHex,
-        errFn,
-        cancelCondition
-      ).then(detail =>
-        // detail has ChangeViewChangeInfo type because the optionsHex always
-        // includes ALL_REVISIONS flag.
-        GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
-      );
-    });
+    if (!changeNum) return;
+    const optionsHex = listChangesOptionsToHex(
+      ...(await this.getChangeOptions())
+    );
+
+    return this._getChangeDetail(
+      changeNum,
+      optionsHex,
+      errFn,
+      cancelCondition
+    ).then(detail =>
+      // detail has ChangeViewChangeInfo type because the optionsHex always
+      // includes ALL_REVISIONS flag.
+      GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
+    );
   }
 
-  _getChangesOptionsHex() {
-    if (
-      window.DEFAULT_DETAIL_HEXES &&
-      window.DEFAULT_DETAIL_HEXES.dashboardPage
-    ) {
-      return window.DEFAULT_DETAIL_HEXES.dashboardPage;
-    }
+  /**
+   * Returns the options to use for querying multiple changes (e.g. dashboard or search).
+   * @return The options hex to use when fetching multiple changes.
+   */
+  private getListChangesOptionsHex() {
     const options = [
       ListChangesOption.LABELS,
       ListChangesOption.DETAILED_ACCOUNTS,
@@ -1221,21 +1221,17 @@
     return listChangesOptionsToHex(...options);
   }
 
-  _getChangeOptionsHex(config?: ServerInfo) {
-    if (
-      window.DEFAULT_DETAIL_HEXES &&
-      window.DEFAULT_DETAIL_HEXES.changePage &&
-      (!config || !(config.receive && config.receive.enable_signed_push))
-    ) {
-      return window.DEFAULT_DETAIL_HEXES.changePage;
-    }
+  async getChangeOptions(): Promise<number[]> {
+    const config = await this.getConfig(false);
 
     // This list MUST be kept in sync with
     // ChangeIT#changeDetailsDoesNotRequireIndex
+    // This list MUST be kept in sync with getResponseFormatOptions
     const options = [
       ListChangesOption.ALL_COMMITS,
       ListChangesOption.ALL_REVISIONS,
       ListChangesOption.CHANGE_ACTIONS,
+      ListChangesOption.DETAILED_ACCOUNTS,
       ListChangesOption.DETAILED_LABELS,
       ListChangesOption.DOWNLOAD_COMMANDS,
       ListChangesOption.MESSAGES,
@@ -1247,7 +1243,32 @@
     if (config?.receive?.enable_signed_push) {
       options.push(ListChangesOption.PUSH_CERTIFICATES);
     }
-    return listChangesOptionsToHex(...options);
+    return options;
+  }
+
+  async getResponseFormatOptions(): Promise<string[]> {
+    const config = await this.getConfig(false);
+
+    // This list MUST be kept in sync with
+    // ChangeIT#changeDetailsDoesNotRequireIndex
+    // This list MUST be kept in sync with getChangeOptions
+    const options = [
+      'ALL_COMMITS',
+      'ALL_REVISIONS',
+      'CHANGE_ACTIONS',
+      'DETAILED_LABELS',
+      'DETAILED_ACCOUNTS',
+      'DOWNLOAD_COMMANDS',
+      'MESSAGES',
+      'SUBMITTABLE',
+      'WEB_LINKS',
+      'SKIP_DIFFSTAT',
+      'SUBMIT_REQUIREMENTS',
+    ];
+    if (config?.receive?.enable_signed_push) {
+      options.push('PUSH_CERTIFICATES');
+    }
+    return options;
   }
 
   /**
@@ -1956,37 +1977,36 @@
     });
   }
 
-  saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    review: ReviewInput
-  ): Promise<Response>;
-
-  saveChangeReview(
+  async saveChangeReview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     review: ReviewInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    review: ReviewInput,
-    errFn?: ErrorCallback
+    errFn?: ErrorCallback,
+    fetchDetail?: boolean
   ) {
+    if (fetchDetail) {
+      review.response_format_options = await this.getResponseFormatOptions();
+    }
     const promises: [Promise<void>, Promise<string>] = [
       this.awaitPendingDiffDrafts(),
       this.getChangeActionURL(changeNum, patchNum, '/review'),
     ];
-    return Promise.all(promises).then(([, url]) =>
-      this._restApiHelper.send({
-        method: HttpMethod.POST,
-        url,
-        body: review,
-        errFn,
-      })
-    );
+    return Promise.all(promises)
+      .then(([, url]) =>
+        this._restApiHelper.send({
+          method: HttpMethod.POST,
+          url,
+          body: review,
+          errFn,
+          parseResponse: true,
+        })
+      )
+      .then(payload => {
+        if (!payload) {
+          return undefined;
+        }
+        return payload as unknown as ReviewResult;
+      });
   }
 
   getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined> {
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 696e142..8b7e3bd 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
@@ -12,10 +12,7 @@
   waitEventLoop,
 } from '../../test/test-utils';
 import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
-  ListChangesOption,
-  listChangesOptionsToHex,
-} from '../../utils/change-util';
+import {listChangesOptionsToHex} from '../../utils/change-util';
 import {
   createAccountDetailWithId,
   createChange,
@@ -47,6 +44,7 @@
   EditPreferencesInfo,
   Hashtag,
   HashtagsInput,
+  ListChangesOption,
   NumericChangeId,
   PARENT,
   ParsedJSON,
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 814d746..1bf5c90 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
@@ -91,6 +91,7 @@
   UrlEncodedCommentId,
   UserId,
   DraftInfo,
+  ReviewResult,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -352,20 +353,10 @@
   saveChangeReview(
     changeNum: ChangeId | NumericChangeId,
     patchNum: RevisionId,
-    review: ReviewInput
-  ): Promise<Response>;
-  saveChangeReview(
-    changeNum: ChangeId | NumericChangeId,
-    patchNum: RevisionId,
     review: ReviewInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  saveChangeReview(
-    changeNum: ChangeId | NumericChangeId,
-    patchNum: RevisionId,
-    review: ReviewInput,
-    errFn?: ErrorCallback
-  ): Promise<Response>;
+    errFn?: ErrorCallback,
+    fetch_detail?: boolean
+  ): Promise<ReviewResult | undefined>;
 
   getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined>;
 
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index a036289..e13cf19 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -20,19 +20,31 @@
     const userModel = testResolver(userModelToken);
     sinon.stub(flagsService, 'isEnabled').returns(true);
     new ServiceWorkerInstaller(flagsService, reportingService, userModel);
+    // TODO: There is a race-condition betweeen preferences being set here
+    // and being loaded from the rest-api-service when the user-model gets created.
+    // So we explicitly wait for the allow_browser_notifications to be false
+    // before continuing with the test.
+    // Ideally there's a way to wait for models to stabilize.
     const prefs = {
       ...createDefaultPreferences(),
+      allow_browser_notifications: false,
+    };
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === false
+    );
+    userModel.setPreferences(prefs);
+
+    const prefs2 = {
+      ...createDefaultPreferences(),
       allow_browser_notifications: true,
     };
-    userModel.setPreferences(prefs);
+    userModel.setPreferences(prefs2);
     await waitUntilObserved(
       userModel.preferences$,
       pref => pref.allow_browser_notifications === true
     );
-    await waitUntilObserved(
-      userModel.preferences$,
-      pref => pref.allow_browser_notifications === true
-    );
+
     assert.isTrue(registerStub.called);
   });
 });
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 d7a842a..531697d 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -68,13 +68,13 @@
   createCommit,
   createConfig,
   createMergeable,
-  createPreferences,
   createServerInfo,
   createSubmittedTogetherInfo,
 } from '../test-data-generators';
 import {
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
+  createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
 import {getBaseUrl} from '../../utils/url-util';
@@ -381,8 +381,7 @@
     return Promise.resolve({});
   },
   getPreferences(): Promise<PreferencesInfo | undefined> {
-    // TODO: Use createDefaultPreferences() instead.
-    return Promise.resolve(createPreferences());
+    return Promise.resolve(createDefaultPreferences());
   },
   getProjectConfig(): Promise<ConfigInfo | undefined> {
     return Promise.resolve(createConfig());
@@ -482,7 +481,7 @@
     return Promise.resolve(new Response());
   },
   saveChangeReview() {
-    return Promise.resolve(new Response());
+    return Promise.resolve({});
   },
   saveChangeStarred(): Promise<Response> {
     return Promise.resolve(new Response());
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 766b407..b23fc7e 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -9,7 +9,6 @@
   SubmitType,
   InheritedBooleanInfoConfiguredValue,
   PermissionAction,
-  CommentSide,
   AppTheme,
   DateFormat,
   TimeFormat,
@@ -42,8 +41,10 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentSide,
   CommitId,
   CommitInfo,
   ConfigArrayParameterInfo,
@@ -51,6 +52,7 @@
   ConfigListParameterInfo,
   ConfigParameterInfo,
   ConfigParameterInfoBase,
+  ContextLine,
   ContributorAgreementInfo,
   CustomKey,
   CustomKeyedValues,
@@ -108,6 +110,7 @@
   SuggestInfo,
   Timestamp,
   TopicName,
+  UrlEncodedCommentId,
   UrlEncodedRepoName,
   UserConfigInfo,
   VotingRangeInfo,
@@ -118,7 +121,7 @@
   CommentRange,
 } from '../api/rest-api';
 import {DiffInfo, IgnoreWhitespaceType} from './diff';
-import {LineNumber} from '../api/diff';
+import {PatchRange, LineNumber} from '../api/diff';
 
 export type {
   AccountId,
@@ -141,6 +144,7 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
   CommentRange,
@@ -151,6 +155,7 @@
   ConfigListParameterInfo,
   ConfigParameterInfo,
   ConfigParameterInfoBase,
+  ContextLine,
   ContributorAgreementInfo,
   DetailedLabelInfo,
   DownloadInfo,
@@ -179,6 +184,7 @@
   MaxObjectSizeLimitInfo,
   NumericChangeId,
   ParentCommitInfo,
+  PatchRange,
   PatchSetNum,
   PatchSetNumber,
   PluginConfigInfo,
@@ -202,6 +208,7 @@
   SuggestInfo,
   Timestamp,
   TopicName,
+  UrlEncodedCommentId,
   UrlEncodedRepoName,
   UserConfigInfo,
   VotingRangeInfo,
@@ -233,9 +240,6 @@
 // The UUID of the suggested fix.
 export type FixId = BrandType<string, '_fixId'>;
 
-// The URL encoded UUID of the comment
-export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
-
 // The ID of the dashboard, in the form of '<ref>:<path>'
 export type DashboardId = BrandType<string, '_dahsboardId'>;
 
@@ -249,6 +253,81 @@
 
 export type UserId = AccountId | GroupId | EmailAddress;
 
+// Must be kept in sync with the ListChangesOption enum.
+// See: java/com/google/gerrit/extensions/client/ListChangesOption.java
+export const ListChangesOption = {
+  LABELS: 0,
+  DETAILED_LABELS: 8,
+
+  // Return information on the current patch set of the change.
+  CURRENT_REVISION: 1,
+  ALL_REVISIONS: 2,
+
+  // If revisions are included, parse the commit object.
+  CURRENT_COMMIT: 3,
+  ALL_COMMITS: 4,
+
+  // If a patch set is included, include the files of the patch set.
+  CURRENT_FILES: 5,
+  ALL_FILES: 6,
+
+  // If accounts are included, include detailed account info.
+  DETAILED_ACCOUNTS: 7,
+
+  // Include messages associated with the change.
+  MESSAGES: 9,
+
+  // Include allowed actions client could perform.
+  CURRENT_ACTIONS: 10,
+
+  // Set the reviewed boolean for the caller.
+  REVIEWED: 11,
+
+  // Include download commands for the caller.
+  DOWNLOAD_COMMANDS: 13,
+
+  // Include patch set weblinks.
+  WEB_LINKS: 14,
+
+  // Include consistency check results.
+  CHECK: 15,
+
+  // Include allowed change actions client could perform.
+  CHANGE_ACTIONS: 16,
+
+  // Include a copy of commit messages including review footers.
+  COMMIT_FOOTERS: 17,
+
+  // Include push certificate information along with any patch sets.
+  PUSH_CERTIFICATES: 18,
+
+  // Include change's reviewer updates.
+  REVIEWER_UPDATES: 19,
+
+  // Set the submittable boolean.
+  SUBMITTABLE: 20,
+
+  // If tracking ids are included, include detailed tracking ids info.
+  TRACKING_IDS: 21,
+
+  // Skip mergeability data.
+  SKIP_MERGEABLE: 22,
+
+  // Skip diffstat computation that compute the insertions field (number of lines inserted) and
+  // deletions field (number of lines deleted)
+  SKIP_DIFFSTAT: 23,
+
+  // Include the evaluated submit requirements for the caller.
+  SUBMIT_REQUIREMENTS: 24,
+
+  // Include custom keyed values.
+  CUSTOM_KEYED_VALUES: 25,
+
+  // Include the 'starred' field, that is if the change is starred by the
+  // current user.
+  STAR: 26,
+};
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
@@ -842,40 +921,6 @@
   commentThreads: CommentThread[];
 }
 
-/**
- * The CommentInfo entity contains information about an inline comment.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
- */
-export interface CommentInfo {
-  id: UrlEncodedCommentId;
-  updated: Timestamp;
-  // TODO(TS): Make this required. Every comment must have patch_set set.
-  patch_set?: RevisionPatchSetNum;
-  path?: string;
-  side?: CommentSide;
-  parent?: number;
-  line?: number;
-  range?: CommentRange;
-  in_reply_to?: UrlEncodedCommentId;
-  message?: string;
-  author?: AccountInfo;
-  tag?: string;
-  unresolved?: boolean;
-  change_message_id?: string;
-  commit_id?: string;
-  context_lines?: ContextLine[];
-  source_content_type?: string;
-}
-
-/**
- * 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 {
-  line_number: number;
-  context_line: string;
-}
-
 export type NameToProjectInfoMap = {[projectName: string]: ProjectInfo};
 
 export type FilePathToDiffInfoMap = {[path: string]: DiffInfo};
@@ -1136,15 +1181,6 @@
 }
 
 /**
- * Defines a patch ranges. Used as input for gr-rest-api methods,
- * doesn't exist in Rest API
- */
-export interface PatchRange {
-  patchNum: RevisionPatchSetNum;
-  basePatchNum: BasePatchSetNum;
-}
-
-/**
  * The CommentInput entity contains information for creating an inline comment
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-input
  */
@@ -1332,6 +1368,7 @@
   add_to_attention_set?: AttentionSetInput[];
   remove_from_attention_set?: AttentionSetInput[];
   ignore_automatic_attention_set_rules?: boolean;
+  response_format_options?: string[];
 }
 
 /**
@@ -1343,6 +1380,7 @@
   labels?: unknown;
   reviewers?: {[key: UserId]: AddReviewerResult};
   ready?: boolean;
+  change_info?: ChangeInfo;
 }
 
 /**
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 4a709b0..7448a27 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -23,12 +23,6 @@
     // it's defined because of limitations from typescript, which don't import .mjs
     page?: unknown;
     hljs?: HighlightJS;
-
-    DEFAULT_DETAIL_HEXES?: {
-      diffPage?: string;
-      changePage?: string;
-      dashboardPage?: string;
-    };
     STATIC_RESOURCE_PATH?: string;
 
     PRELOADED_QUERIES?: {
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 40474e9..9f2374a 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -102,11 +102,6 @@
   return !!x && Number.isInteger(x) && (x as number) > 0;
 }
 
-export interface FileRange {
-  basePath?: string;
-  path: string;
-}
-
 export interface FetchRequest {
   url: string;
   fetchOptions?: AuthRequestInit;
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 932d91a3..5079abd 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -31,80 +31,6 @@
   REWRITE: 'REWRITE',
 };
 
-// Must be kept in sync with the ListChangesOption enum and protobuf.
-export const ListChangesOption = {
-  LABELS: 0,
-  DETAILED_LABELS: 8,
-
-  // Return information on the current patch set of the change.
-  CURRENT_REVISION: 1,
-  ALL_REVISIONS: 2,
-
-  // If revisions are included, parse the commit object.
-  CURRENT_COMMIT: 3,
-  ALL_COMMITS: 4,
-
-  // If a patch set is included, include the files of the patch set.
-  CURRENT_FILES: 5,
-  ALL_FILES: 6,
-
-  // If accounts are included, include detailed account info.
-  DETAILED_ACCOUNTS: 7,
-
-  // Include messages associated with the change.
-  MESSAGES: 9,
-
-  // Include allowed actions client could perform.
-  CURRENT_ACTIONS: 10,
-
-  // Set the reviewed boolean for the caller.
-  REVIEWED: 11,
-
-  // Include download commands for the caller.
-  DOWNLOAD_COMMANDS: 13,
-
-  // Include patch set weblinks.
-  WEB_LINKS: 14,
-
-  // Include consistency check results.
-  CHECK: 15,
-
-  // Include allowed change actions client could perform.
-  CHANGE_ACTIONS: 16,
-
-  // Include a copy of commit messages including review footers.
-  COMMIT_FOOTERS: 17,
-
-  // Include push certificate information along with any patch sets.
-  PUSH_CERTIFICATES: 18,
-
-  // Include change's reviewer updates.
-  REVIEWER_UPDATES: 19,
-
-  // Set the submittable boolean.
-  SUBMITTABLE: 20,
-
-  // If tracking ids are included, include detailed tracking ids info.
-  TRACKING_IDS: 21,
-
-  // Skip mergeability data.
-  SKIP_MERGEABLE: 22,
-
-  // Skip diffstat computation that compute the insertions field (number of lines inserted) and
-  // deletions field (number of lines deleted)
-  SKIP_DIFFSTAT: 23,
-
-  // Include the evaluated submit requirements for the caller.
-  SUBMIT_REQUIREMENTS: 24,
-
-  // Include custom keyed values.
-  CUSTOM_KEYED_VALUES: 25,
-
-  // Include the 'starred' field, that is if the change is starred by the
-  // current user.
-  STAR: 26,
-};
-
 export function listChangesOptionsToHex(...args: number[]) {
   let v = 0;
   for (let i = 0; i < args.length; i++) {
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index 1d209f9..6e53c16 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -16,6 +16,7 @@
   AccountId,
   ChangeStates,
   CommitId,
+  ListChangesOption,
   NumericChangeId,
   PatchSetNum,
 } from '../types/common';
@@ -27,7 +28,6 @@
   changePath,
   changeStatuses,
   isRemovableReviewer,
-  ListChangesOption,
   listChangesOptionsToHex,
   hasHumanReviewer,
 } from './change-util';
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index f5649b6..7fad329 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -28,6 +28,7 @@
   SavingState,
   NewDraftInfo,
   isNew,
+  CommentInput,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -557,3 +558,36 @@
   }
   return comment;
 }
+
+export function convertToCommentInput(comment: Comment): CommentInput {
+  const output: CommentInput = {
+    message: comment.message,
+    unresolved: comment.unresolved,
+  };
+
+  if (comment.id) {
+    output.id = comment.id;
+  }
+  if (comment.path) {
+    output.path = comment.path;
+  }
+  if (comment.side) {
+    output.side = comment.side;
+  }
+  if (comment.line) {
+    output.line = comment.line;
+  }
+  if (comment.range) {
+    output.range = comment.range;
+  }
+  if (comment.in_reply_to) {
+    output.in_reply_to = comment.in_reply_to;
+  }
+  if (comment.updated) {
+    output.updated = comment.updated;
+  }
+  if (comment.tag) {
+    output.tag = comment.tag;
+  }
+  return output;
+}
diff --git a/polygerrit-ui/app/utils/message-util.ts b/polygerrit-ui/app/utils/message-util.ts
index 5acdf33..fffe612 100644
--- a/polygerrit-ui/app/utils/message-util.ts
+++ b/polygerrit-ui/app/utils/message-util.ts
@@ -7,7 +7,8 @@
 import {ChangeId, ChangeMessageInfo} from '../types/common';
 
 function getRevertChangeIdFromMessage(msg: ChangeMessageInfo): ChangeId {
-  const REVERT_REGEX = /^Created a revert of this change as (.*)$/;
+  const REVERT_REGEX =
+    /^Created a revert of this change as .*?(I[0-9a-f]{40})$/;
   const changeId = msg.message.match(REVERT_REGEX)?.[1];
   if (!changeId) throw new Error('revert changeId not found');
   return changeId as ChangeId;
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts
index 22a5e4d..64d765a 100644
--- a/polygerrit-ui/app/utils/message-util_test.ts
+++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -14,12 +14,8 @@
     const messages = [
       {
         ...createChangeMessage(),
-        message: 'Created a revert of this change as 123',
-        tag: MessageTag.TAG_REVERT as ReviewInputTag,
-      },
-      {
-        ...createChangeMessage(),
-        message: 'Created a revert of this change as xyz',
+        message:
+          'Created a revert of this change as If02ca1cd494579d6bb92a157bf1819e3689cd6b1',
         tag: MessageTag.TAG_REVERT as ReviewInputTag,
       },
       {
@@ -30,8 +26,27 @@
     ];
 
     assert.deepEqual(getRevertCreatedChangeIds(messages), [
-      '123' as ChangeId,
-      'xyz' as ChangeId,
+      'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
+    ]);
+  });
+
+  test('getRevertCreatedChangeIds with extra spam', () => {
+    const messages = [
+      {
+        ...createChangeMessage(),
+        message:
+          'Created a revert of this change as IIf02ca1cd494579d6bb92a157bf1819e3689cd6b1',
+        tag: MessageTag.TAG_REVERT as ReviewInputTag,
+      },
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as abc',
+        tag: undefined,
+      },
+    ];
+
+    assert.deepEqual(getRevertCreatedChangeIds(messages), [
+      'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
     ]);
   });
 });
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 6ceaa4f..c44e907 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -3,7 +3,13 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {BasePatchSetNum, PARENT, RevisionPatchSetNum} from '../types/common';
+import {
+  AuthInfo,
+  BasePatchSetNum,
+  PARENT,
+  RevisionPatchSetNum,
+} from '../types/common';
+import {AuthType} from '../api/rest-api';
 
 export function getBaseUrl(): string {
   // window is not defined in service worker, therefore no CANONICAL_PATH
@@ -11,6 +17,37 @@
   return self.CANONICAL_PATH || '';
 }
 
+/**
+ * Return the url to use for login. If the server configuration
+ * contains the `loginUrl` in the `auth` section then that custom url
+ * will be used, defaults to `/login` otherwise.
+ *
+ * @param authConfig the auth section of gerrit configuration if defined
+ */
+export function loginUrl(authConfig: AuthInfo | undefined): string {
+  const baseUrl = getBaseUrl();
+  const customLoginUrl = authConfig?.login_url;
+  const authType = authConfig?.auth_type;
+  if (
+    customLoginUrl &&
+    (authType === AuthType.HTTP || authType === AuthType.HTTP_LDAP)
+  ) {
+    return customLoginUrl.startsWith('http')
+      ? customLoginUrl
+      : baseUrl + customLoginUrl;
+  } else {
+    // Strip the canonical path from the path since needing canonical in
+    // the path is unneeded and breaks the url.
+    const defaultUrl = `${baseUrl}/login/`;
+    const postFix = encodeURIComponent(
+      window.location.pathname.substring(baseUrl.length) +
+        window.location.search +
+        window.location.hash
+    );
+    return defaultUrl + postFix;
+  }
+}
+
 export interface PatchRangeParams {
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index e2ff837..32b9da2 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -3,20 +3,22 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {BasePatchSetNum, RevisionPatchSetNum} from '../api/rest-api';
+import {AuthType, BasePatchSetNum, RevisionPatchSetNum} from '../api/rest-api';
 import '../test/common-test-setup';
 import {
-  getBaseUrl,
   encodeURL,
+  getBaseUrl,
+  getPatchRangeExpression,
+  loginUrl,
+  PatchRangeParams,
   singleDecodeURL,
   toPath,
   toPathname,
   toSearchParams,
-  getPatchRangeExpression,
-  PatchRangeParams,
   sameOrigin,
 } from './url-util';
 import {assert} from '@open-wc/testing';
+import {createAuth} from '../test/test-data-generators';
 
 suite('url-util tests', () => {
   suite('getBaseUrl tests', () => {
@@ -36,6 +38,47 @@
     });
   });
 
+  suite('loginUrl tests', () => {
+    const authConfig = createAuth();
+
+    test('default url if auth.loginUrl is not defined', () => {
+      const current = encodeURIComponent(
+        window.location.pathname + window.location.search + window.location.hash
+      );
+      assert.deepEqual(loginUrl(undefined), '/login/' + current);
+      assert.deepEqual(loginUrl(authConfig), '/login/' + current);
+    });
+
+    test('default url if auth type is not HTTP or HTTP_LDAP', () => {
+      const defaultUrl =
+        '/login/' +
+        encodeURIComponent(
+          window.location.pathname +
+            window.location.search +
+            window.location.hash
+        );
+      const customLoginUrl = '/custom';
+      authConfig.login_url = customLoginUrl;
+
+      authConfig.auth_type = AuthType.LDAP;
+      assert.deepEqual(loginUrl(authConfig), defaultUrl);
+      authConfig.auth_type = AuthType.OPENID_SSO;
+      assert.deepEqual(loginUrl(authConfig), defaultUrl);
+      authConfig.auth_type = AuthType.OAUTH;
+      assert.deepEqual(loginUrl(authConfig), defaultUrl);
+    });
+
+    test('use auth.loginUrl when defined', () => {
+      const customLoginUrl = '/custom';
+      authConfig.login_url = customLoginUrl;
+
+      authConfig.auth_type = AuthType.HTTP;
+      assert.deepEqual(loginUrl(authConfig), customLoginUrl);
+      authConfig.auth_type = AuthType.HTTP_LDAP;
+      assert.deepEqual(loginUrl(authConfig), customLoginUrl);
+    });
+  });
+
   suite('url encoding and decoding tests', () => {
     suite('encodeURL', () => {
       test('does not encode alphanumeric chars', () => {
diff --git a/proto/cache.proto b/proto/cache.proto
index 79bfa9e..7e38d92 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -252,7 +252,7 @@
     // Next ID: 3
     message TagProto {
       bytes id = 1;
-      bytes flags = 2;
+      bytes flags = 2; // org.roaringbitmap.RoaringBitmap serialized as ByteString
     }
     repeated TagProto tag = 3;
   }
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index dbfef44..b5892794 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -48,14 +48,6 @@
     // Disable extra font load from paper-styles
     window.polymerSkipLoadingFontRoboto = true;
     window.CLOSURE_NO_DEPS = true;
-    window.DEFAULT_DETAIL_HEXES = {lb}
-      {if $defaultChangeDetailHex}
-        changePage: '{$defaultChangeDetailHex}',
-      {/if}
-      {if $defaultDashboardHex}
-        dashboardPage: '{$defaultDashboardHex}',
-      {/if}
-    {rb};
     window.PRELOADED_QUERIES = {lb}
       {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
         dashboardQuery: [{for $query in $dashboardQuery}{$query},{/for}],
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 592e4a7..4293d7c 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -22,6 +22,7 @@
 HTTPCOMP_VERS = "4.5.2"
 JETTY_VERS = "9.4.51.v20230217"
 BYTE_BUDDY_VERSION = "1.10.7"
+ROARING_BITMAP_VERSION = "0.9.44"
 
 def java_dependencies():
     maven_jar(
@@ -733,3 +734,15 @@
         artifact = "org.objenesis:objenesis:3.0.1",
         sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
     )
+
+    maven_jar(
+        name = "roaringbitmap",
+        artifact = "org.roaringbitmap:RoaringBitmap:" + ROARING_BITMAP_VERSION,
+        sha1 = "d25b4bcb67193d587f6e0617da2c6f84e2d02a9c",
+    )
+
+    maven_jar(
+        name = "roaringbitmap-shims",
+        artifact = "org.roaringbitmap:shims:" + ROARING_BITMAP_VERSION,
+        sha1 = "e22be0d690a99c046bf9f57106065a77edad1eda",
+    )
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 3765575..afefbb2 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -18,7 +18,7 @@
     "rollup-plugin-define": "^1.0.1",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "^4.7.2"
+    "typescript": "^4.9.5"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index c6b3eab..92afd0d 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -1951,10 +1951,10 @@
   dependencies:
     tslib "^1.8.1"
 
-typescript@^4.7.2:
-  version "4.7.2"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4"
-  integrity sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==
+typescript@^4.9.5:
+  version "4.9.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
+  integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"